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

feat: 传新的听书与有声书动画。现在支持全局悬浮窗与主页悬浮底栏模式时,胶囊布局。胶囊支持长按之

HapeLee 1 месяц назад
Родитель
Сommit
db8e715d8a
69 измененных файлов с 5478 добавлено и 889 удалено
  1. 1 0
      app/src/main/AndroidManifest.xml
  2. 33 0
      app/src/main/java/io/legado/app/core/ui/player/PlayerMorphAnchors.kt
  3. 68 0
      app/src/main/java/io/legado/app/core/ui/player/PlayerMorphCover.kt
  4. 626 0
      app/src/main/java/io/legado/app/core/ui/player/PlayerMorphHost.kt
  5. 3 0
      app/src/main/java/io/legado/app/di/appModule.kt
  6. 16 0
      app/src/main/java/io/legado/app/domain/gateway/PlaybackCapsuleGateway.kt
  7. 33 0
      app/src/main/java/io/legado/app/domain/model/PlaybackCapsuleState.kt
  8. 2 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueue.kt
  9. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  10. 52 0
      app/src/main/java/io/legado/app/feature/readaloud/overlay/CapsulePosition.kt
  11. 57 0
      app/src/main/java/io/legado/app/feature/readaloud/overlay/ReadAloudOverlayPermissionRoute.kt
  12. 14 0
      app/src/main/java/io/legado/app/help/LifecycleHelp.kt
  13. 18 0
      app/src/main/java/io/legado/app/service/AudioPlayService.kt
  14. 26 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  15. 163 0
      app/src/main/java/io/legado/app/service/playback/PlaybackCapsuleGatewayAdapter.kt
  16. 314 0
      app/src/main/java/io/legado/app/service/readaloud/ReadAloudOverlayWindow.kt
  17. 8 1
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayCoordinator.kt
  18. 10 14
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt
  19. 16 1
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt
  20. 269 175
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt
  21. 6 4
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  22. 0 8
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  23. 0 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  24. 0 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  25. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  26. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  27. 448 0
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudBarCapsule.kt
  28. 90 0
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudPlayerMorphHost.kt
  29. 23 0
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudPlayerOverlayBus.kt
  30. 42 51
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudShellHost.kt
  31. 254 0
      app/src/main/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFrames.kt
  32. 301 0
      app/src/main/java/io/legado/app/ui/book/readaloud/morph/ReadAloudMorphState.kt
  33. 22 13
      app/src/main/java/io/legado/app/ui/book/readaloud/player/PlayerThemeOverride.kt
  34. 1 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudConfigIntentMapper.kt
  35. 0 2
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  36. 72 18
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  37. 0 75
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerRouteScreen.kt
  38. 24 16
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  39. 29 9
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  40. 294 114
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  41. 1 0
      app/src/main/java/io/legado/app/ui/main/MainIntent.kt
  42. 206 219
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  43. 4 7
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  44. 2 12
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  45. 98 21
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  46. 27 0
      app/src/main/java/io/legado/app/ui/main/PlaybackPlayerAnchors.kt
  47. 8 2
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  48. 4 2
      app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt
  49. 156 84
      app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt
  50. 2 1
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  51. 8 1
      app/src/main/res/values-zh-rCN/strings.xml
  52. 8 1
      app/src/main/res/values-zh-rHK/strings.xml
  53. 8 1
      app/src/main/res/values-zh-rTW/strings.xml
  54. 8 1
      app/src/main/res/values/strings.xml
  55. 117 0
      app/src/test/java/io/legado/app/core/ui/player/PlayerMorphAnchorsTest.kt
  56. 190 0
      app/src/test/java/io/legado/app/core/ui/player/PlayerMorphHostInteractionTest.kt
  57. 2 0
      app/src/test/java/io/legado/app/model/ReadAloudSessionStoreTest.kt
  58. 134 0
      app/src/test/java/io/legado/app/service/readaloud/PlaybackCapsuleGatewayAdapterTest.kt
  59. 73 0
      app/src/test/java/io/legado/app/service/readaloud/PlaybackCapsuleSelectionTest.kt
  60. 92 0
      app/src/test/java/io/legado/app/service/readaloud/ReadAloudOverlayWindowTest.kt
  61. 115 0
      app/src/test/java/io/legado/app/ui/book/audio/AudioPlayViewModelInitializationTest.kt
  62. 152 0
      app/src/test/java/io/legado/app/ui/book/readaloud/ReadAloudBarCapsuleInteractionTest.kt
  63. 408 0
      app/src/test/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFramesTest.kt
  64. 27 0
      app/src/test/java/io/legado/app/ui/main/MainPlayerRouteRestoreTest.kt
  65. 68 0
      app/src/test/java/io/legado/app/ui/main/PlaybackPlayerAnchorsTest.kt
  66. 13 15
      app/src/test/java/io/legado/app/ui/main/ReadAloudSwitchToClassicTargetTest.kt
  67. 127 0
      app/src/test/java/io/legado/app/ui/widget/components/FloatingBottomBarInteractionTest.kt
  68. 73 0
      docs/dev/readaloud-player-morph.md
  69. 7 14
      gradle.properties

+ 1 - 0
app/src/main/AndroidManifest.xml

@@ -8,6 +8,7 @@
     <!-- 自 Android 17 (API 37) 起本地网络保护强制生效:接受局域网入站连接需要该运行时权限,
          否则 Web 服务对局域网内其他设备不可达。targetSdk < 37 时不得声明该权限。 -->
     <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.READ_PHONE_STATE" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

+ 33 - 0
app/src/main/java/io/legado/app/core/ui/player/PlayerMorphAnchors.kt

@@ -0,0 +1,33 @@
+package io.legado.app.core.ui.player
+
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.geometry.Rect
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+
+/** 最多等待 60 个布局帧;宿主缺失或封面无法布局时也必须允许播放页打开。 */
+internal const val PLAYER_ANCHOR_WAIT_FRAMES = 60
+
+/** 返回 true 才使用胶囊形变;关闭胶囊立即淡入,不等待任何布局帧。 */
+internal suspend fun awaitPlayerMorphAnchors(
+    morph: ReadAloudMorphState,
+    awaitCapsuleAnchor: Boolean,
+): Boolean {
+    if (!awaitCapsuleAnchor) return false
+    var previousPanel = Rect.Zero
+    var previousCover = Rect.Zero
+    var stableFrames = 0
+    repeat(PLAYER_ANCHOR_WAIT_FRAMES) {
+        withFrameNanos { }
+        val panel = morph.panelStartBounds
+        val cover = morph.coverStartBounds
+        val ready = !morph.screenBounds.isEmpty &&
+                (!morph.coverPageVisible || !morph.capsuleCoverLinked || !morph.coverEndBounds.isEmpty) &&
+                morph.hasCapsuleAnchors
+        stableFrames =
+            if (ready && panel == previousPanel && cover == previousCover) stableFrames + 1 else 0
+        if (stableFrames >= 2) return true
+        previousPanel = panel
+        previousCover = cover
+    }
+    return false
+}

+ 68 - 0
app/src/main/java/io/legado/app/core/ui/player/PlayerMorphCover.kt

@@ -0,0 +1,68 @@
+package io.legado.app.core.ui.player
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import kotlinx.coroutines.flow.filterNotNull
+import kotlin.math.abs
+
+/** 在动画两端更新封面页可见性,避免每帧重组页面;过渡期间保留起始页面状态。 */
+@Composable
+fun TrackPlayerMorphCoverPage(pagerState: PagerState) {
+    val morph = LocalReadAloudMorph.current
+    LaunchedEffect(morph, pagerState) {
+        if (morph == null) return@LaunchedEffect
+        snapshotFlow {
+            if (morph.expanded || morph.progress.value <= 0f) {
+                pagerState.currentPage == 1 && abs(pagerState.currentPageOffsetFraction) < 0.001f
+            } else null
+        }.filterNotNull().collect(morph::reportCoverPageVisible)
+    }
+}
+
+/** 播放页封面:保持目标布局,过渡期间让位给宿主唯一的飞行封面。 */
+@Composable
+fun PlayerMorphCover(
+    appearance: PlayerMorphAppearance,
+    modifier: Modifier = Modifier,
+    circular: Boolean = false,
+) {
+    val morph = LocalReadAloudMorph.current
+    val radiusPx = with(LocalDensity.current) { 8.dp.toPx() }
+    BookCoverImage(
+        name = appearance.bookName,
+        author = appearance.author,
+        path = appearance.coverPath,
+        sourceOrigin = appearance.sourceOrigin,
+        bookUrl = null,
+        preferCache = true,
+        showLoadingPlaceholder = morph == null,
+        requestBuilder = { if (morph != null) size(coil3.size.Size(1024, 1024)) },
+        modifier = modifier
+            .onGloballyPositioned { coordinates ->
+                morph?.reportCoverEnd(
+                    bounds = coordinates.boundsInRoot(),
+                    cornerRadiusPx = if (circular) {
+                        minOf(coordinates.size.width, coordinates.size.height) / 2f
+                    } else radiusPx,
+                )
+            }
+            .clip(if (circular) CircleShape else RoundedCornerShape(8.dp))
+            .graphicsLayer {
+                alpha =
+                    if (morph == null || morph.expanded || !morph.hasCapsuleAnchors || !morph.capsuleCoverLinked || morph.coverEndBounds.isEmpty) 1f else 0f
+            },
+    )
+}

+ 626 - 0
app/src/main/java/io/legado/app/core/ui/player/PlayerMorphHost.kt

@@ -0,0 +1,626 @@
+package io.legado.app.core.ui.player
+
+import android.os.Build
+import android.view.RoundedCorner
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+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.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+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.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.layout
+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.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.Velocity
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
+import io.legado.app.ui.book.readaloud.morph.PlayerPanelCornerRadii
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import io.legado.app.ui.book.readaloud.morph.computeMorphFaceBlend
+import io.legado.app.ui.book.readaloud.morph.computeMorphFlyingCoverAlpha
+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.theme.ProvideThemeOverride
+import io.legado.app.ui.theme.ThemeOverrideState
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.ui.widget.components.player.PlayerBackground
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+/** 拖动结算时用速度投影的时长:0.1s 后进度落在哪,就按哪边吸附。 */
+private const val SETTLE_PROJECTION_SECONDS = 0.1f
+
+/** 飞行封面的固定解码尺寸:够 52% 宽的播放页封面清晰,又不随布局尺寸变化而重发请求。 */
+private val FLYING_COVER_DECODE_SIZE = coil3.size.Size(1024, 1024)
+
+/**
+ * 两种播放页共用的 morph 宿主:与胶囊同窗口、同 composition,由单一进度驱动。
+ *
+ * 三层(顺序即叠放顺序):
+ * 1. **视觉面**:始终按全屏尺寸布局,只更新最终坐标下的裁剪轮廓,morph 期间零 measure;
+ * 2. **内容**:不做几何缩放,只吃透明度(`progress` 的最后一段),避免文字被拉糊;
+ * 3. **飞行封面**:唯一同时吃几何插值的内容元素,从胶囊封面长到播放页封面。
+ *
+ * 播放页因此不再使用 `ModalBottomSheet`(独立窗口):跨窗口既拿不到对方坐标,
+ * 也无法统一裁剪,做不出「面板从胶囊长出来」的观感。
+ * 速度 / 定时 / 朗读设置仍是小弹层,继续用 `AppModalBottomSheet`——独立窗口在这里反而是优点。
+ */
+@Stable
+data class PlayerMorphAppearance(
+    val bookName: String,
+    val author: String,
+    val coverPath: String?,
+    val sourceOrigin: String?,
+    val bgMode: Int,
+)
+
+@Composable
+fun PlayerMorphHost(
+    appearance: PlayerMorphAppearance,
+    playerTheme: ThemeOverrideState?,
+    morph: ReadAloudMorphState,
+    visible: Boolean,
+    awaitCapsuleAnchor: Boolean = false,
+    backEnabled: Boolean = true,
+    predictiveBackEnabled: Boolean = true,
+    onDismiss: () -> Unit,
+    content: @Composable (onCollapse: () -> Unit) -> Unit,
+) {
+    val view = LocalView.current
+    val scope = rememberCoroutineScope()
+    val currentVisible by rememberUpdatedState(visible)
+    var backSettleJob by remember { mutableStateOf<Job?>(null) }
+    // 进度每帧都在变,用 derivedStateOf 收敛成布尔,避免宿主整体重组。
+    // 必须带 visible 作为 key:derivedStateOf 的 lambda 只在首次组合捕获 visible,
+    // 不带 key 的话 visible 变化不会反映到结果里。
+    val hostPresent by remember(visible) {
+        derivedStateOf { visible || morph.progress.value > 0.001f }
+    }
+    val currentDismiss by rememberUpdatedState(onDismiss)
+    val currentBackEnabled by rememberUpdatedState(backEnabled)
+
+    val collapse: (Float) -> Unit = { velocity ->
+        scope.launch {
+            morph.animateTo(0f, initialVelocity = velocity)
+            currentDismiss()
+        }
+    }
+
+    LaunchedEffect(visible, awaitCapsuleAnchor) {
+        if (visible) {
+            // 服务刚启动时胶囊还没有布局。等两个稳定帧再冻结起点;
+            // 首次会话由胶囊宿主提前布局;关闭胶囊时只淡入,不虚构坐标。
+            if (morph.progress.value == 0f) {
+                morph.prepareOpeningAnchors()
+                val ready = awaitPlayerMorphAnchors(morph, awaitCapsuleAnchor)
+                if (!ready) morph.useFadeOnlyOpening()
+            }
+            morph.animateTo(1f)
+        } else if (morph.progress.value > 0f) {
+            // 外部直接关闭(切经典控制、朗读停止等):补一次收起动画。
+            morph.animateTo(0f)
+        }
+    }
+    // 播放页打开时重建注册,保持高于后挂载的阅读页返回处理器。
+    // 始终用同一个返回处理器;关闭预测性动画时仍拦截返回,仅不预览手势进度。
+    key(hostPresent) {
+        PredictiveBackHandler(enabled = hostPresent && backEnabled) { events ->
+            // AndroidX 的 enabled 更新存在一帧延迟,仍须收集 flow 才能结束此回调。
+            if (!currentVisible || !currentBackEnabled) {
+                events.collect { }
+                return@PredictiveBackHandler
+            }
+            backSettleJob?.cancel()
+            val startProgress = morph.progress.value
+            var lastProgress = startProgress
+            var lastTimeNanos = 0L
+            var releaseVelocity = 0f
+            try {
+                if (predictiveBackEnabled) {
+                    morph.progress.stop()
+                    morph.dragging = true
+                }
+                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()
+                morph.dragging = false
+                // 确认后才真正关闭;键盘/三键返回的 flow 没有进度,也走同一路径。
+                backSettleJob = scope.launch {
+                    morph.animateTo(0f, initialVelocity = releaseVelocity)
+                    currentDismiss()
+                }
+            } catch (cancelled: CancellationException) {
+                // 手势取消时 collector 已被取消,恢复动画要交给宿主 scope。
+                // 外部已经关闭播放页时不再恢复,避免与外部收起动画争抢。
+                if (predictiveBackEnabled && currentVisible) {
+                    backSettleJob = scope.launch { morph.animateTo(1f) }
+                }
+                throw cancelled
+            } finally {
+                morph.dragging = false
+            }
+        }
+    }
+
+    if (!hostPresent) return
+
+    CompositionLocalProvider(LocalReadAloudMorph provides morph) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .onGloballyPositioned { coordinates ->
+                    morph.reportScreenBounds(coordinates.boundsInRoot())
+                    // API 31 以下或系统没有提供圆角时使用方角,不反射 OEM 私有 API。
+                    val windowBounds = coordinates.boundsInWindow()
+                    val insets = view.rootWindowInsets
+                    fun radius(position: Int, horizontalInset: Float, verticalInset: Float): Float {
+                        if (Build.VERSION.SDK_INT < 31) return 0f
+                        val corner = insets?.getRoundedCorner(position) ?: return 0f
+                        return (corner.radius - maxOf(
+                            horizontalInset,
+                            verticalInset
+                        )).coerceAtLeast(0f)
+                    }
+                    if (Build.VERSION.SDK_INT >= 31) {
+                        val rightInset =
+                            (view.rootView.width - windowBounds.right).coerceAtLeast(0f)
+                        val bottomInset =
+                            (view.rootView.height - windowBounds.bottom).coerceAtLeast(0f)
+                        morph.reportScreenCorners(
+                            PlayerPanelCornerRadii(
+                                radius(
+                                    RoundedCorner.POSITION_TOP_LEFT,
+                                    windowBounds.left,
+                                    windowBounds.top
+                                ),
+                                radius(
+                                    RoundedCorner.POSITION_TOP_RIGHT,
+                                    rightInset,
+                                    windowBounds.top
+                                ),
+                                radius(
+                                    RoundedCorner.POSITION_BOTTOM_RIGHT,
+                                    rightInset,
+                                    bottomInset
+                                ),
+                                radius(
+                                    RoundedCorner.POSITION_BOTTOM_LEFT,
+                                    windowBounds.left,
+                                    bottomInset
+                                ),
+                            )
+                        )
+                    } else {
+                        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)
+                                )
+                            }
+                        },
+                        // 结束与取消都要结算,否则进度会停在中间,
+                        // 两个胶囊按进度淡出会表现为「胶囊消失」。
+                        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)),
+        ) {
+            MorphPanelSurface(
+                morph = morph,
+                appearance = appearance,
+                playerTheme = playerTheme,
+            )
+            MorphPlayerContent(
+                morph = morph,
+                playerTheme = playerTheme,
+            ) { content { collapse(0f) } }
+            MorphFlyingCover(morph = morph, appearance = appearance)
+        }
+    }
+
+}
+
+/**
+ * 视觉面:全屏尺寸 + 最终坐标下的轮廓裁剪,内容就是播放页自己的背景。
+ *
+ * 几何在延迟读取(`graphicsLayer`)里每帧算,不产生重组;
+ * 背景用 [PlayerBackground] 而不是纯色,形变全程看到的都已经是播放页的底色,
+ * 落地时背景不需要再淡入一次。
+ */
+@Composable
+private fun MorphPanelSurface(
+    morph: ReadAloudMorphState,
+    appearance: PlayerMorphAppearance,
+    playerTheme: ThemeOverrideState?,
+) {
+    // 面板是「缩小版的播放页」,必须和播放页共用封面取色主题:
+    // 否则形变期间读到的是应用默认配色(一块灰),和落地后的播放页完全两个颜色。
+    ProvideThemeOverride(playerTheme) {
+        val containerColor = LegadoTheme.colorScheme.surface
+        // 进度 0 时面板与胶囊完全重合,底色必须是胶囊的脸。
+        // 优先使用胶囊上报的底色;没有起点颜色时沿用播放页颜色,不猜测胶囊主题。
+        val collapsedColor = morph.panelStartColor.takeIf { it.isSpecified } ?: containerColor
+
+        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
+                    // 在最终坐标空间裁剪,圆角不再被 scaleX/scaleY 压成椭圆。
+                    // 全屏布局保持不变,背景和内容使用完全相同的轮廓。
+                    shape = MorphPanelClipShape(
+                        frame.bounds.translate(-screen.left, -screen.top), frame.cornerRadii,
+                    )
+                    // 不给全屏裁剪图层加 elevation:它会生成巨大的动态投影,
+                    // 并与尚未淡出的胶囊阴影叠加。胶囊自身的投影保留。
+                    shadowElevation = 0f
+                }
+                .drawBehind {
+                    // 底色:胶囊的脸 → 播放页底色。背景模式选「透明」时播放页自身没有底,
+                    // 这一层就是唯一的面。
+                    val blend =
+                        if (morph.panelFrame() == null) 1f else computeMorphFaceBlend(morph.progress.value)
+                    drawRect(color = lerpColor(collapsedColor, containerColor, blend))
+                },
+        ) {
+            // 播放页背景(封面模糊 / 流光)按同一条交接曲线淡入:
+            // 起点是胶囊的脸,展开后才是播放页背景,收起时原路退回胶囊。
+            PlayerBackground(
+                name = appearance.bookName,
+                author = appearance.author,
+                path = appearance.coverPath,
+                sourceOrigin = appearance.sourceOrigin,
+                bgMode = appearance.bgMode,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .graphicsLayer {
+                        alpha =
+                            if (morph.panelFrame() == null) 1f else computeMorphFaceBlend(morph.progress.value)
+                    },
+            )
+        }
+    }
+}
+
+/**
+ * 内容层:不做几何缩放,只按进度淡入淡出,并按当前面板矩形裁剪。
+ *
+ * 不缩放是为了不让文字被非等比拉伸;裁剪是为了让淡入发生在面板之内。
+ * 未完全展开时清空语义,避免 TalkBack 读到还看不见的播放页内容。
+ */
+@Composable
+private fun MorphPlayerContent(
+    morph: ReadAloudMorphState,
+    playerTheme: ThemeOverrideState?,
+    content: @Composable () -> Unit,
+) {
+    val expanded by remember { derivedStateOf { morph.expanded } }
+    val dragging by remember { derivedStateOf { morph.dragging } }
+    val contentSemantics = if (expanded) Modifier else Modifier.clearAndSetSemantics {}
+    // 内容只吃透明度、始终按全屏尺寸布局,所以未展开时虽然看不见却仍可命中,必须显式屏蔽。
+    // 但拖动期间要放行:Initial 阶段吞事件会把宿主自己的拖拽也吞掉。
+    val inputGate = if (expanded || dragging) {
+        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
+            },
+    ) {
+        ProvideThemeOverride(playerTheme) {
+            content()
+        }
+    }
+}
+
+/**
+ * 飞行封面:唯一同时吃几何插值与内容绘制的元素。
+ *
+ * 三条刻意的取舍:
+ * 1. **封面页不交叉淡出**:alpha 常驻 1,进度到 1 才让位给页面封面;目录/歌词页返回时,
+ *    封面留在胶囊端点并在收起末段渐显。此前按 `1 - veil` 淡出,
+ *    最后 15% 会与页面封面交叉溶解 —— 两者只要有一点尺寸/圆角差异就会露出来,
+ *    观感是「封面飞到一半就散了」。
+ * 2. **帧只在延迟读取里算**:帧每帧都变,写在组合体里会让整个封面每帧重组,
+ *    而 `AsyncImage` 每次重组都可能重发请求(带 crossfade)——封面自己就会闪。
+ * 3. **固定解码尺寸**:请求里写死尺寸后,布局尺寸变化不会改请求,也就不重发。
+ *    两端宽高比不同(正圆 ↔ 5:7),必须按帧的真实宽高重新布局;
+ *    像面板那样用非等比 scale 硬缩会把封面压扁。
+ */
+@Composable
+private fun MorphFlyingCover(
+    morph: ReadAloudMorphState,
+    appearance: PlayerMorphAppearance,
+) {
+    val density = LocalDensity.current
+    BookCoverImage(
+        name = appearance.bookName,
+        author = appearance.author,
+        path = appearance.coverPath,
+        sourceOrigin = appearance.sourceOrigin,
+        // 听书是书维度场景,本地优先不跑书源脚本
+        bookUrl = null,
+        preferCache = true,
+        // 飞行封面不需要占位图和交叉淡入:它一出现就该是封面本身。
+        showLoadingPlaceholder = false,
+        requestBuilder = { size(FLYING_COVER_DECODE_SIZE) },
+        modifier = Modifier
+            .layout { measurable, _ ->
+                val frame = morph.coverFrame() ?: return@layout layout(0, 0) {}
+                val width = frame.bounds.width.roundToInt().coerceAtLeast(0)
+                val height = frame.bounds.height.roundToInt().coerceAtLeast(0)
+                val placeable = measurable.measure(Constraints.fixed(width, height))
+                layout(width, height) { placeable.place(0, 0) }
+            }
+            .graphicsLayer {
+                val frame = morph.coverFrame() ?: return@graphicsLayer
+                // 进度到 1 时播放页自己的封面就在同一位置,形变层让位(是让位,不是淡出)。
+                alpha = computeMorphFlyingCoverAlpha(morph.progress.value, morph.coverPageVisible)
+                rotationZ = frame.imageRotationDeg
+                // layout 必须取整,但画面位置和大小保留浮点精度。
+                // 默认中心变换原点下补偿半个尺寸差,保证两端边界完全对齐。
+                translationX =
+                    frame.bounds.left - morph.screenBounds.left + (frame.bounds.width - size.width) / 2f
+                translationY =
+                    frame.bounds.top - morph.screenBounds.top + (frame.bounds.height - size.height) / 2f
+                scaleX =
+                    if (size.width > 0f) frame.bounds.width / size.width * frame.imageScale else 1f
+                scaleY =
+                    if (size.height > 0f) frame.bounds.height / size.height * frame.imageScale else 1f
+                shape = RoundedCornerShape(with(density) { frame.cornerRadiusPx.toDp() })
+                clip = true
+            }
+            .drawWithContent {
+                drawContent()
+                val ring = morph.capsuleProgressRing ?: return@drawWithContent
+                val alpha = 1f - computeMorphPanelAlpha(morph.progress.value)
+                if (alpha <= 0f) return@drawWithContent
+                val inset = ring.strokeWidthPx / 2f
+                drawArc(
+                    color = ring.color.copy(alpha = ring.color.alpha * alpha),
+                    startAngle = -90f,
+                    sweepAngle = 360f * ring.progress.coerceIn(0f, 1f),
+                    useCenter = false,
+                    topLeft = Offset(inset, inset),
+                    size = Size(
+                        (size.width - ring.strokeWidthPx).coerceAtLeast(0f),
+                        (size.height - ring.strokeWidthPx).coerceAtLeast(0f)
+                    ),
+                    style = Stroke(ring.strokeWidthPx, cap = StrokeCap.Round),
+                )
+            },
+    )
+}
+
+/**
+ * 可滚动页面的下拉收起:只在内容已经滚到顶部、仍有下滑余量时接管。
+ *
+ * 用 `onPostScroll`(子节点消费之后剩下的位移)而不是 `onPreScroll`,
+ * 这样正文 / 目录滚动优先,两者不打架。
+ */
+private fun morphCollapseNestedScroll(
+    morph: ReadAloudMorphState,
+    scope: CoroutineScope,
+    onCollapse: (Float) -> Unit,
+): NestedScrollConnection = object : NestedScrollConnection {
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource,
+    ): Offset {
+        if (source != NestedScrollSource.UserInput) return Offset.Zero
+        if (available.y <= 0f || morph.progress.value <= 0f) return Offset.Zero
+        morph.dragging = true
+        val delta = available.y / morph.dragRangePx()
+        scope.launch {
+            morph.progress.snapTo((morph.progress.value - delta).coerceIn(0f, 1f))
+        }
+        return Offset(0f, available.y)
+    }
+
+    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+        // 只有真的被这条通道驱动过才结算,普通列表惯性不受影响。
+        if (morph.dragging) {
+            morph.dragging = false
+            settleMorph(
+                morph = morph,
+                scope = scope,
+                onCollapse = onCollapse,
+                progressVelocity = -available.y / morph.dragRangePx(),
+            )
+        }
+        return Velocity.Zero
+    }
+}
+
+/**
+ * 把手势速度换算成进度速度(进度/秒)。
+ *
+ * 手指向上 = 进度变大,而 [VelocityTracker] 的 y 轴向上为负,所以取反。
+ */
+private fun morphDragVelocity(
+    tracker: VelocityTracker,
+    morph: ReadAloudMorphState,
+): Float = -tracker.calculateVelocity().y / morph.dragRangePx()
+
+/**
+ * 拖动结束后的结算:按「当前进度 + 速度投影」判断落点,决定吸附回展开还是真正收起。
+ *
+ * 光看当前进度会在轻甩时判错方向;光看速度又会让慢速拖过一半时弹回去。
+ *
+ * 收起必须走 [onCollapse](动画归零 + 关闭播放页),否则会停在进度 0 的空面板、
+ * 而两个胶囊按 `1 - progress` 已经淡到看不见。
+ */
+private fun settleMorph(
+    morph: ReadAloudMorphState,
+    scope: CoroutineScope,
+    onCollapse: (Float) -> Unit,
+    progressVelocity: Float = 0f,
+) {
+    scope.launch {
+        val projected = morph.progress.value + progressVelocity * SETTLE_PROJECTION_SECONDS
+        if (projected >= 0.5f) {
+            morph.animateTo(1f, initialVelocity = progressVelocity)
+        } else {
+            onCollapse(progressVelocity)
+        }
+    }
+}
+
+/**
+ * 按形变矩形裁剪。
+ *
+ * 用值类型而不是可变对象:`graphicsLayer` 只在 `shape` 变化时重算轮廓,
+ * 同一个实例会被判定为「没变」而沿用旧轮廓,需要用值相等性来表达「这一帧的几何」。
+ */
+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),
+        ),
+    )
+}
+
+private fun lerpColor(from: Color, to: Color, t: Float): Color = Color(
+    red = from.red + (to.red - from.red) * t,
+    green = from.green + (to.green - from.green) * t,
+    blue = from.blue + (to.blue - from.blue) * t,
+    alpha = from.alpha + (to.alpha - from.alpha) * t,
+)
+
+/** 保留下层布局用于形变背景,但模态播放浮层存在时不向辅助服务暴露下层控件。 */
+internal fun Modifier.playerUnderlaySemantics(playerPresent: Boolean): Modifier =
+    if (playerPresent) clearAndSetSemantics {} else this

+ 3 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -233,6 +233,8 @@ import io.legado.app.ui.association.ImportHttpTtsViewModel
 import io.legado.app.ui.association.ImportReplaceRuleViewModel
 import io.legado.app.ui.association.ImportRssSourceViewModel
 import io.legado.app.ui.association.ImportTxtTocRuleViewModel
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.service.playback.PlaybackCapsuleGatewayAdapter
 import io.legado.app.ui.book.audio.AudioPlayCoordinator
 import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
@@ -402,6 +404,7 @@ val appModule = module {
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
+    single<PlaybackCapsuleGateway> { PlaybackCapsuleGatewayAdapter(get(), get()) }
     singleOf(::MainNavRouteTracker)
     // R2.3:会话每个所有者一份。ReadBook.callBack 的身份是「阅读页已挂载」信号
     // (prefetchForOpen / upData 判 callBack != null),register 还会给上一个持有者

+ 16 - 0
app/src/main/java/io/legado/app/domain/gateway/PlaybackCapsuleGateway.kt

@@ -0,0 +1,16 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import kotlinx.coroutines.flow.StateFlow
+
+interface PlaybackCapsuleGateway {
+    val state: StateFlow<PlaybackCapsuleState>
+    fun setSessionAvailable(source: PlaybackCapsuleSource, available: Boolean)
+
+    /** 已加载但尚未启动服务的有声书,作为可恢复的暂停会话。 */
+    fun prepareAudioBook(bookUrl: String)
+    fun clearPreparedAudioBook(bookUrl: String)
+    fun togglePause(source: PlaybackCapsuleSource)
+    fun stop(source: PlaybackCapsuleSource)
+}

+ 33 - 0
app/src/main/java/io/legado/app/domain/model/PlaybackCapsuleState.kt

@@ -0,0 +1,33 @@
+package io.legado.app.domain.model
+
+import androidx.compose.runtime.Stable
+
+enum class PlaybackCapsuleSource { ReadAloud, AudioBook }
+
+@Stable
+data class PlaybackCapsuleState(
+    val source: PlaybackCapsuleSource? = null,
+    val bookUrl: String = "",
+    val bookName: String = "",
+    val author: String = "",
+    val coverPath: String? = null,
+    val sourceOrigin: String? = null,
+    val isPaused: Boolean = true,
+    val progress: Float = 0f,
+    val inBookshelf: Boolean = true,
+    val chapterTitle: String = "",
+    val chapterIndex: Int = -1,
+)
+
+/** 正在播放的会话优先;都暂停时保留当前会话,停止后回退到仍存活的会话。 */
+fun selectPlaybackCapsule(
+    readAloud: PlaybackCapsuleState,
+    audioBook: PlaybackCapsuleState,
+    currentSource: PlaybackCapsuleSource?,
+): PlaybackCapsuleState {
+    val candidates = listOf(readAloud, audioBook).filter { it.source != null }
+    val playing = candidates.filter { !it.isPaused }
+    val preferred = playing.ifEmpty { candidates }
+    return preferred.firstOrNull { it.source == currentSource }
+        ?: preferred.firstOrNull() ?: PlaybackCapsuleState()
+}

+ 2 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueue.kt

@@ -35,6 +35,8 @@ data class ReadAloudPlaybackInfo(
     val engineName: String = "",
     val characterName: String = "",
     val roleType: SpeechRoleType = SpeechRoleType.Narrator,
+    val chapterTitle: String = "",
+    val chapterIndex: Int = -1,
 )
 
 /** Position-based playback queue independent from reader pagination. */

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -57,6 +57,7 @@ data class ReadAloudSettings(
     val keepReadAloudOnExit: Boolean = false,
     val showReadAloudCapsule: Boolean = true,
     val capsuleAutoCollapse: Boolean = true,
+    /** 相对屏幕底部中央默认中心的 dp 偏移;应用内外共用,尺寸/系统栏不参与。 */
     val capsuleOffsetX: Float = 0f,
     val capsuleOffsetY: Float = 0f,
     val mediaButtonPerNext: Boolean = false,

+ 52 - 0
app/src/main/java/io/legado/app/feature/readaloud/overlay/CapsulePosition.kt

@@ -0,0 +1,52 @@
+package io.legado.app.feature.readaloud.overlay
+
+import kotlin.math.roundToInt
+
+/** 位置保存为相对屏幕底部中央的中心偏移,尺寸、导航栏和折叠状态不参与保存。 */
+const val CapsuleDefaultCenterFromBottomDp = 72f
+
+fun capsuleWindowY(offsetYDp: Float, density: Float, heightPx: Int): Int =
+    ((CapsuleDefaultCenterFromBottomDp - offsetYDp) * density - heightPx / 2f).roundToInt()
+
+fun capsuleOffsetY(windowY: Int, density: Float, heightPx: Int): Float =
+    CapsuleDefaultCenterFromBottomDp - (windowY + heightPx / 2f) / density
+
+
+/** 只在松手时吸附靠近的左右边缘,中间区域保持手动位置。 */
+fun snapCapsuleOffsetX(offsetXDp: Float, screenWidthDp: Float): Float {
+    val edge = screenWidthDp.coerceAtLeast(0f) / 2f
+    val x = offsetXDp.coerceIn(-edge, edge)
+    return if (edge - kotlin.math.abs(x) <= 72f) {
+        if (x < 0f) -edge else edge
+    } else x
+}
+
+/** 保存的是完整封面的中心;吸附时中心正好在屏幕边线上。 */
+fun capsuleDockSide(offsetXDp: Float, screenWidthDp: Float): Int {
+    val edge = screenWidthDp / 2f
+    if (edge <= 0f) return 0
+    return when {
+        offsetXDp <= -edge + 0.5f -> -1
+        offsetXDp >= edge - 0.5f -> 1
+        else -> 0
+    }
+}
+
+fun capsuleVisibleOffsetX(offsetXDp: Float, screenWidthDp: Float, visibleWidthDp: Float): Float {
+    val max = ((screenWidthDp - visibleWidthDp) / 2f).coerceAtLeast(0f)
+    return offsetXDp.coerceIn(-max, max)
+}
+
+
+/** 迷你封面中心可到边线上;展开时随同一进度连续让位,始终保留完整封面。 */
+fun capsulePresentationOffsetX(
+    offsetXDp: Float,
+    screenWidthDp: Float,
+    footprintWidthDp: Float,
+    expansion: Float
+): Float {
+    val edge = screenWidthDp.coerceAtLeast(0f) / 2f
+    val mini = offsetXDp.coerceIn(-edge, edge)
+    val expanded = capsuleVisibleOffsetX(offsetXDp, screenWidthDp, footprintWidthDp)
+    return mini + (expanded - mini) * expansion.coerceIn(0f, 1f)
+}

+ 57 - 0
app/src/main/java/io/legado/app/feature/readaloud/overlay/ReadAloudOverlayPermissionRoute.kt

@@ -0,0 +1,57 @@
+package io.legado.app.feature.readaloud.overlay
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import io.legado.app.R
+import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
+import io.legado.app.utils.toastOnUi
+
+/** 权限与 Activity Result 留在 Android host;没有新增设置或播放状态所有者。 */
+@Composable
+fun ReadAloudOverlayPermissionRoute() {
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+    var granted by remember(context) { mutableStateOf(Settings.canDrawOverlays(context)) }
+    val launcher =
+        rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+            granted = Settings.canDrawOverlays(context)
+        }
+    DisposableEffect(lifecycleOwner, context) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) granted = Settings.canDrawOverlays(context)
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+    TinyClickableSettingItem(
+        title = stringResource(R.string.read_aloud_overlay_permission),
+        description = stringResource(if (granted) R.string.read_aloud_overlay_granted else R.string.read_aloud_overlay_required),
+        onClick = {
+            try {
+                launcher.launch(
+                    Intent(
+                        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+                        Uri.parse("package:${context.packageName}")
+                    )
+                )
+            } catch (_: ActivityNotFoundException) {
+                context.toastOnUi(R.string.read_aloud_overlay_unavailable)
+            }
+        },
+    )
+}

+ 14 - 0
app/src/main/java/io/legado/app/help/LifecycleHelp.kt

@@ -5,6 +5,8 @@ import android.app.Application
 import android.os.Bundle
 import io.legado.app.base.BaseService
 import io.legado.app.utils.LogUtils
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import java.lang.ref.WeakReference
 
 /**
@@ -15,6 +17,12 @@ object LifecycleHelp : Application.ActivityLifecycleCallbacks {
 
     private const val TAG = "LifecycleHelp"
 
+    private val startedActivities = mutableSetOf<Activity>()
+    private val _appVisible = MutableStateFlow(false)
+
+    /** 至少一个应用 Activity 可见时不显示应用外朗读窗口。 */
+    val appVisible = _appVisible.asStateFlow()
+
     private val activities: MutableList<WeakReference<Activity>> = arrayListOf()
     private val services: MutableList<WeakReference<BaseService>> = arrayListOf()
     private var appFinishedListener: (() -> Unit)? = null
@@ -66,10 +74,14 @@ object LifecycleHelp : Application.ActivityLifecycleCallbacks {
     }
 
     override fun onActivityStarted(activity: Activity) {
+        startedActivities.add(activity)
+        _appVisible.value = true
         LogUtils.d(TAG, "${activity::class.simpleName} onStart")
     }
 
     override fun onActivityDestroyed(activity: Activity) {
+        startedActivities.remove(activity)
+        _appVisible.value = startedActivities.isNotEmpty()
         LogUtils.d(TAG, "${activity::class.simpleName} onDestroy")
         for (temp in activities) {
             if (temp.get() != null && temp.get() === activity) {
@@ -87,6 +99,8 @@ object LifecycleHelp : Application.ActivityLifecycleCallbacks {
     }
 
     override fun onActivityStopped(activity: Activity) {
+        startedActivities.remove(activity)
+        _appVisible.value = startedActivities.isNotEmpty()
         LogUtils.d(TAG, "${activity::class.simpleName} onStop")
     }
 

+ 18 - 0
app/src/main/java/io/legado/app/service/AudioPlayService.kt

@@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.res.Configuration
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.media.AudioManager
@@ -33,6 +34,9 @@ import io.legado.app.constant.NotificationId
 import io.legado.app.constant.Status
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.service.readaloud.ReadAloudOverlayWindow
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.coroutine.Coroutine
@@ -130,9 +134,14 @@ class AudioPlayService : BaseService(),
     private var cover: Bitmap =
         BitmapFactory.decodeResource(appCtx.resources, R.drawable.ic_launcher)!!
 
+    private var capsuleOverlayWindow: ReadAloudOverlayWindow? = null
+
     override fun onCreate() {
         super.onCreate()
         isRun = true
+        GlobalContext.get().get<PlaybackCapsuleGateway>()
+            .setSessionAvailable(PlaybackCapsuleSource.AudioBook, true)
+        capsuleOverlayWindow = ReadAloudOverlayWindow(this, PlaybackCapsuleSource.AudioBook)
         exoPlayer.addListener(this)
         AudioPlay.registerService(this)
         initMediaSession()
@@ -199,7 +208,16 @@ class AudioPlayService : BaseService(),
         return super.onStartCommand(intent, flags, startId)
     }
 
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        capsuleOverlayWindow?.onConfigurationChanged()
+    }
+
     override fun onDestroy() {
+        GlobalContext.get().get<PlaybackCapsuleGateway>()
+            .setSessionAvailable(PlaybackCapsuleSource.AudioBook, false)
+        capsuleOverlayWindow?.close()
+        capsuleOverlayWindow = null
         super.onDestroy()
         if (useWakeLock) {
             wakeLock.release()

+ 26 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.res.Configuration
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.graphics.Color
@@ -63,6 +64,9 @@ 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.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.service.readaloud.ReadAloudOverlayWindow
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.LogUtils
@@ -93,6 +97,8 @@ import splitties.systemservices.wifiManager
 abstract class BaseReadAloudService : BaseService(),
     AudioManager.OnAudioFocusChangeListener {
 
+    private var capsuleOverlayWindow: ReadAloudOverlayWindow? = null
+
     companion object {
         @JvmStatic
         @Volatile
@@ -283,6 +289,11 @@ abstract class BaseReadAloudService : BaseService(),
         stopRequested = false
         isRun = true
         pause = false
+        get<PlaybackCapsuleGateway>(PlaybackCapsuleGateway::class.java).setSessionAvailable(
+            PlaybackCapsuleSource.ReadAloud,
+            true
+        )
+        capsuleOverlayWindow = ReadAloudOverlayWindow(this)
         // 新朗读会话默认跟随当前显示页(用户手动翻页脱离后由阅读界面负责恢复)
         sessionStore.restoreReadAloudFollow()
         observeLiveBus()
@@ -324,7 +335,18 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        capsuleOverlayWindow?.onConfigurationChanged()
+    }
+
     override fun onDestroy() {
+        get<PlaybackCapsuleGateway>(PlaybackCapsuleGateway::class.java).setSessionAvailable(
+            PlaybackCapsuleSource.ReadAloud,
+            false
+        )
+        capsuleOverlayWindow?.close()
+        capsuleOverlayWindow = null
         ReadBook.upReadTime()
         super.onDestroy()
         prepareReadAloudGeneration++
@@ -667,6 +689,8 @@ abstract class BaseReadAloudService : BaseService(),
             val chapterLength = readerReadAloudChapter?.chapterLength ?: chapterPosition
             sessionStore.updatePlayback(
                 ReadAloudPlaybackInfo(
+                    chapterTitle = readerReadAloudChapter?.title.orEmpty(),
+                    chapterIndex = readerReadAloudChapter?.chapterIndex ?: -1,
                     chapterPosition = chapterPosition,
                     chapterLength = chapterLength.coerceAtLeast(1),
                     text = contentList.getOrNull(nowSpeak).orEmpty(),
@@ -840,6 +864,8 @@ abstract class BaseReadAloudService : BaseService(),
     private fun publishPlaybackInfo(cursor: ReadAloudPlaybackCursor) {
         val cue = playbackQueue.cues.getOrNull(cursor.cueIndex) ?: return
         sessionStore.updatePlayback(ReadAloudPlaybackInfo(
+            chapterTitle = readerReadAloudChapter?.title.orEmpty(),
+            chapterIndex = readerReadAloudChapter?.chapterIndex ?: -1,
             chapterPosition = cue.chapterStart + cursor.offset,
             chapterLength = playbackQueue.cues.lastOrNull()?.chapterEnd ?: cue.chapterEnd,
             text = cue.text,

+ 163 - 0
app/src/main/java/io/legado/app/service/playback/PlaybackCapsuleGatewayAdapter.kt

@@ -0,0 +1,163 @@
+package io.legado.app.service.playback
+
+import android.app.Application
+import androidx.lifecycle.Observer
+import com.jeremyliao.liveeventbus.LiveEventBus
+import io.legado.app.constant.EventBus
+import io.legado.app.constant.Status
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.selectPlaybackCapsule
+import io.legado.app.model.AudioPlay
+import io.legado.app.model.ReadAloud
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.model.ReadBook
+import io.legado.app.service.AudioPlayService
+import io.legado.app.service.BaseReadAloudService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.runningFold
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+
+/** 既有两种播放引擎的 Android 适配器,仅投影胶囊状态,不初始化播放器或加载书籍。 */
+class PlaybackCapsuleGatewayAdapter(
+    private val application: Application,
+    private val sessionStore: ReadAloudSessionStore,
+) : PlaybackCapsuleGateway {
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+    private val availability =
+        MutableStateFlow(BaseReadAloudService.isRun to AudioPlayService.isRun)
+    private val preparedAudioBookUrl = MutableStateFlow<String?>(null)
+
+    // 服务建立时记录归属;模型换书后晚到的旧服务销毁不能清掉新书准备会话。
+    private var serviceAudioBookUrl = AudioPlay.book?.bookUrl.takeIf { AudioPlayService.isRun }
+    private val events = callbackFlow {
+        val observer = Observer<Any> { trySend(Unit) }
+        val keys = listOf(
+            EventBus.AUDIO_STATE, EventBus.AUDIO_PROGRESS, EventBus.AUDIO_SIZE,
+            EventBus.AUDIO_SUB_TITLE, EventBus.ALOUD_STATE, EventBus.TTS_PROGRESS
+        )
+        keys.forEach { LiveEventBus.get<Any>(it).observeForever(observer) }
+        trySend(Unit)
+        awaitClose { keys.forEach { LiveEventBus.get<Any>(it).removeObserver(observer) } }
+    }
+
+    override val state =
+        combine(sessionStore.state, availability, preparedAudioBookUrl, events) { _, _, _, _ ->
+            readAloudSnapshot() to audioBookSnapshot()
+        }.runningFold(PlaybackCapsuleState()) { previous, (readAloud, audioBook) ->
+            val selected = selectPlaybackCapsule(readAloud, audioBook, previous.source)
+            // source 归空立即触发退场,保留最后一张封面供淡出,避免中途变成占位图。
+            if (selected.source == null) previous.copy(source = null, isPaused = true) else selected
+        }.stateIn(
+            scope, SharingStarted.WhileSubscribed(5000),
+            selectPlaybackCapsule(readAloudSnapshot(), audioBookSnapshot(), null)
+        )
+
+    override fun setSessionAvailable(source: PlaybackCapsuleSource, available: Boolean) {
+        if (source == PlaybackCapsuleSource.AudioBook) {
+            if (available) serviceAudioBookUrl = AudioPlay.book?.bookUrl
+            else {
+                serviceAudioBookUrl?.let(::clearPreparedAudioBook)
+                serviceAudioBookUrl = null
+            }
+        }
+        availability.update {
+            when (source) {
+                PlaybackCapsuleSource.ReadAloud -> available to it.second
+                PlaybackCapsuleSource.AudioBook -> it.first to available
+            }
+        }
+    }
+
+    override fun prepareAudioBook(bookUrl: String) {
+        preparedAudioBookUrl.value = bookUrl
+    }
+
+    override fun clearPreparedAudioBook(bookUrl: String) {
+        preparedAudioBookUrl.compareAndSet(bookUrl, null)
+    }
+
+    private fun readAloudSnapshot(): PlaybackCapsuleState {
+        if (!availability.value.first) return PlaybackCapsuleState()
+        val book = ReadBook.book ?: return PlaybackCapsuleState()
+        val session = sessionStore.state.value
+        return PlaybackCapsuleState(
+            PlaybackCapsuleSource.ReadAloud, book.bookUrl, book.name,
+            book.author, book.getDisplayCover(), book.origin,
+            session.status != ReadAloudSessionStatus.Playing,
+            (session.playback.chapterPosition.toFloat() / session.playback.chapterLength.coerceAtLeast(
+                1
+            )).coerceIn(0f, 1f),
+            chapterTitle = session.playback.chapterTitle,
+            chapterIndex = session.playback.chapterIndex
+        )
+    }
+
+    private fun audioBookSnapshot(): PlaybackCapsuleState {
+        val book = AudioPlay.book ?: return PlaybackCapsuleState()
+        if (!availability.value.second && preparedAudioBookUrl.value != book.bookUrl) return PlaybackCapsuleState()
+        return PlaybackCapsuleState(
+            PlaybackCapsuleSource.AudioBook,
+            book.bookUrl,
+            book.name,
+            book.author,
+            book.getDisplayCover(),
+            book.origin,
+            !availability.value.second || AudioPlay.status != Status.PLAY,
+            (AudioPlay.durChapterPos.toFloat() / AudioPlay.durAudioSize.coerceAtLeast(1)).coerceIn(
+                0f,
+                1f
+            ),
+            inBookshelf = AudioPlay.inBookshelf,
+            chapterTitle = AudioPlay.durChapter?.takeIf {
+                it.bookUrl == book.bookUrl && it.index == AudioPlay.durChapterIndex
+            }?.title.orEmpty(),
+            chapterIndex = AudioPlay.durChapterIndex
+        )
+    }
+
+    override fun togglePause(source: PlaybackCapsuleSource) {
+        if (!ownsSelectedSession(source)) return
+        when (source) {
+            PlaybackCapsuleSource.ReadAloud -> if (BaseReadAloudService.pause) ReadAloud.resume(
+                application
+            ) else ReadAloud.pause(application)
+
+            PlaybackCapsuleSource.AudioBook -> when (if (AudioPlayService.isRun) AudioPlay.status else Status.STOP) {
+                Status.PLAY -> AudioPlay.pause(application)
+                Status.PAUSE -> AudioPlay.resume(application)
+                else -> AudioPlay.loadOrUpPlayUrl()
+            }
+        }
+    }
+
+    override fun stop(source: PlaybackCapsuleSource) {
+        if (!ownsSelectedSession(source)) return
+        when (source) {
+            PlaybackCapsuleSource.ReadAloud -> ReadAloud.stop(application)
+            PlaybackCapsuleSource.AudioBook -> {
+                AudioPlay.book?.bookUrl?.let(::clearPreparedAudioBook)
+                AudioPlay.stop()
+            }
+        }
+    }
+
+    /** 拦截来自旧胶囊的点击,已准备的有声书可以恢复,停止且没有准备会话时不再接受动作。 */
+    private fun ownsSelectedSession(source: PlaybackCapsuleSource): Boolean =
+        state.value.source == source &&
+                when (source) {
+                    PlaybackCapsuleSource.ReadAloud -> availability.value.first && BaseReadAloudService.isRun
+                    PlaybackCapsuleSource.AudioBook -> (availability.value.second && AudioPlayService.isRun) ||
+                            (AudioPlay.book?.bookUrl != null && preparedAudioBookUrl.value == AudioPlay.book?.bookUrl)
+                }
+}

+ 314 - 0
app/src/main/java/io/legado/app/service/readaloud/ReadAloudOverlayWindow.kt

@@ -0,0 +1,314 @@
+package io.legado.app.service.readaloud
+
+import android.app.AppOpsManager
+import android.content.Intent
+import android.graphics.PixelFormat
+import android.hardware.display.DisplayManager
+import android.os.Build
+import android.provider.Settings
+import android.view.Display
+import android.view.Gravity
+import android.view.WindowManager
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.feature.readaloud.overlay.capsulePresentationOffsetX
+import io.legado.app.feature.readaloud.overlay.capsuleWindowY
+import io.legado.app.help.LifecycleHelp
+import io.legado.app.base.BaseService
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.ui.book.read.ReadAloudCapsule
+import io.legado.app.ui.main.MainActivity
+import io.legado.app.ui.main.MainIntent
+import io.legado.app.ui.theme.AppTheme
+import io.legado.app.utils.LogUtils
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.koin.java.KoinJavaComponent.get
+import kotlin.math.roundToInt
+
+/**
+ * Android 平台窗口适配器。随现有朗读或有声书前台服务创建/销毁,不另建服务或播放状态。
+ * 只在本应用没有可见 Activity 且获准悬浮时挂载 WRAP_CONTENT 窗口。
+ */
+class ReadAloudOverlayWindow(
+    private val service: BaseService,
+    private val ownerSource: PlaybackCapsuleSource = PlaybackCapsuleSource.ReadAloud,
+) : AutoCloseable {
+    private val scope = service.lifecycleScope
+    private val settingsGateway: ReadAloudSettingsGateway =
+        get(ReadAloudSettingsGateway::class.java)
+    private val configurationGateway: AppUiConfigurationGateway =
+        get(AppUiConfigurationGateway::class.java)
+    private val player: PlaybackCapsuleGateway = get(PlaybackCapsuleGateway::class.java)
+    private var settings by mutableStateOf(settingsGateway.currentSettings)
+    private var playerState by mutableStateOf(player.state.value)
+    private var windowOffsetX = settings.capsuleOffsetX
+    private var windowOffsetY = settings.capsuleOffsetY
+    private var windowExpansion = 1f
+    private var view: ComposeView? = null
+    private var owner: OverlayViewOwner? = null
+    private var windowManager: WindowManager? = null
+    private var parameters: WindowManager.LayoutParams? = null
+    private var visibilityJob: Job? = null
+    private var closed = false
+    private val jobs = mutableListOf<Job>()
+    private val appOps = service.getSystemService(AppOpsManager::class.java)
+    private val permissionListener = AppOpsManager.OnOpChangedListener { operation, packageName ->
+        if (operation == AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW && packageName == service.packageName) {
+            scope.launch { refreshVisibility() }
+        }
+    }
+
+    init {
+        appOps.startWatchingMode(
+            AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,
+            service.packageName,
+            permissionListener
+        )
+        jobs += scope.launch {
+            settingsGateway.settings.collect { updated ->
+                settings = updated
+                refreshVisibility()
+            }
+        }
+        jobs += scope.launch { player.state.collect { playerState = it; refreshVisibility() } }
+        jobs += scope.launch { LifecycleHelp.appVisible.collect { refreshVisibility() } }
+    }
+
+    private fun shouldShow(): Boolean = !closed && shouldShowExternalCapsule(
+        settings.showReadAloudCapsule,
+        LifecycleHelp.appVisible.value,
+        Settings.canDrawOverlays(service),
+        ownerSource,
+        playerState.source,
+    )
+
+    private fun refreshVisibility() {
+        if (!shouldShow()) {
+            visibilityJob?.cancel()
+            visibilityJob = null
+            removeWindow()
+        } else if (view == null && visibilityJob?.isActive != true) {
+            // Activity 重建的短暂不可见不应闪出应用外窗口。
+            visibilityJob = scope.launch {
+                delay(150)
+                if (shouldShow() && view == null) addWindow()
+            }
+        }
+    }
+
+    private fun addWindow() {
+        val display =
+            service.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
+                ?: return
+        val displayContext = service.createDisplayContext(display)
+        val context = if (Build.VERSION.SDK_INT >= 30) {
+            displayContext.createWindowContext(
+                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+                null
+            )
+        } else displayContext
+        val manager = context.getSystemService(WindowManager::class.java)
+        val params = WindowManager.LayoutParams(
+            WindowManager.LayoutParams.WRAP_CONTENT,
+            WindowManager.LayoutParams.WRAP_CONTENT,
+            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+                    WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+            PixelFormat.TRANSLUCENT,
+        ).apply {
+            gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+            if (Build.VERSION.SDK_INT >= 30) setFitInsetsTypes(0)
+            softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
+            setTitle("ReadAloudCapsule")
+        }
+        val viewOwner = OverlayViewOwner()
+        val capsuleView = ComposeView(context).apply {
+            setViewTreeLifecycleOwner(viewOwner)
+            setViewTreeSavedStateRegistryOwner(viewOwner)
+            setViewTreeViewModelStoreOwner(viewOwner)
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                val configuration by configurationGateway.configuration.collectAsStateWithLifecycle()
+                AppTheme(configuration, applyBackground = false) {
+                    ReadAloudCapsule(
+                        bookName = playerState.bookName,
+                        author = playerState.author,
+                        coverPath = playerState.coverPath,
+                        sourceOrigin = playerState.sourceOrigin,
+                        isPaused = playerState.isPaused,
+                        offsetXDp = settings.capsuleOffsetX,
+                        offsetYDp = settings.capsuleOffsetY,
+                        progress = playerState.progress,
+                        autoCollapse = settings.capsuleAutoCollapse,
+                        onPositionChanged = ::savePosition,
+                        onTogglePause = { playerState.source?.let(player::togglePause) },
+                        onStop = { playerState.source?.let(player::stop) },
+                        onOpenPlayer = {
+                            val intent =
+                                if (playerState.source == PlaybackCapsuleSource.AudioBook) {
+                                    MainIntent.createAudioPlayIntent(
+                                        service,
+                                        playerState.bookUrl,
+                                        playerState.inBookshelf
+                                    )
+                                } else {
+                                    Intent(service, MainActivity::class.java).apply {
+                                        putExtra(MainIntent.EXTRA_OPEN_READ_ALOUD_PLAYER, true)
+                                    }
+                                }
+                            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+                            service.startActivity(intent)
+                        },
+                        onWindowPositionChanged = ::moveWindow,
+                    )
+                }
+            }
+        }
+        windowManager = manager
+        parameters = params
+        owner = viewOwner
+        view = capsuleView
+        applySavedPosition(updateWindow = false)
+        try {
+            manager.addView(capsuleView, params)
+            capsuleView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
+                if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
+                    // 折叠/展开只改变窗口大小,保持屏幕上的中心坐标。
+                    applyPosition()
+                }
+            }
+        } catch (error: RuntimeException) {
+            // 权限被撤销或系统拒绝窗口时保留应用内路径,并明确记录平台失败。
+            LogUtils.d("ReadAloudOverlay", "Unable to attach overlay: $error")
+            removeWindow()
+        }
+    }
+
+    private fun applySavedPosition(updateWindow: Boolean = true) {
+        windowOffsetX = settings.capsuleOffsetX
+        windowOffsetY = settings.capsuleOffsetY
+        applyPosition(updateWindow)
+    }
+
+    private fun applyPosition(updateWindow: Boolean = true) {
+        val params = parameters ?: return
+        val density = service.resources.displayMetrics.density
+        val bounds =
+            if (Build.VERSION.SDK_INT >= 30) windowManager?.maximumWindowMetrics?.bounds else null
+        val screenWidth = bounds?.width() ?: service.resources.displayMetrics.widthPixels
+        params.x = (capsulePresentationOffsetX(
+            windowOffsetX, screenWidth / density,
+            (view?.width ?: 0) / density, windowExpansion
+        ) * density).roundToInt()
+        params.y = capsuleWindowY(windowOffsetY, density, view?.height ?: 0)
+        if (updateWindow) updatePosition()
+    }
+
+    private fun moveWindow(xDp: Float, yDp: Float, expansion: Float) {
+        windowOffsetX = xDp
+        windowOffsetY = yDp
+        windowExpansion = expansion
+        applyPosition()
+    }
+
+    private fun updatePosition() {
+        val capsule = view ?: return
+        val params = parameters ?: return
+        if (!capsule.isAttachedToWindow) return
+        try {
+            windowManager?.updateViewLayout(capsule, params)
+        } catch (error: RuntimeException) {
+            LogUtils.d("ReadAloudOverlay", "Unable to move overlay: $error")
+            removeWindow()
+        }
+    }
+
+    private fun savePosition(x: Float, y: Float) {
+        // 保存完整封面的中心,不能用半封面窗口的中心反推,否则切回应用会跳位。
+        scope.launch { settingsGateway.update { it.copy(capsuleOffsetX = x, capsuleOffsetY = y) } }
+    }
+
+    fun onConfigurationChanged() {
+        removeWindow()
+        refreshVisibility()
+    }
+
+    private fun removeWindow() {
+        val capsule = view
+        view = null
+        if (capsule != null) {
+            try {
+                if (capsule.isAttachedToWindow) windowManager?.removeViewImmediate(capsule)
+            } catch (error: RuntimeException) {
+                LogUtils.d("ReadAloudOverlay", "Unable to detach overlay: $error")
+            }
+            capsule.disposeComposition()
+        }
+        owner?.close()
+        owner = null
+        parameters = null
+        windowManager = null
+    }
+
+    override fun close() {
+        closed = true
+        visibilityJob?.cancel()
+        jobs.forEach { it.cancel() }
+        appOps.stopWatchingMode(permissionListener)
+        removeWindow()
+    }
+}
+
+internal fun shouldShowExternalCapsule(
+    enabled: Boolean,
+    appVisible: Boolean,
+    permissionGranted: Boolean,
+    ownerSource: PlaybackCapsuleSource? = null,
+    selectedSource: PlaybackCapsuleSource? = null,
+): Boolean =
+    enabled && !appVisible && permissionGranted && ownerSource == selectedSource
+
+private class OverlayViewOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner,
+    AutoCloseable {
+    private val registry = LifecycleRegistry(this)
+    private val savedState = SavedStateRegistryController.create(this)
+    override val lifecycle: Lifecycle get() = registry
+    override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
+    override val viewModelStore = ViewModelStore()
+
+    init {
+        savedState.performAttach()
+        savedState.performRestore(null)
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_START)
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+    }
+
+    override fun close() {
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+        registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+        viewModelStore.clear()
+    }
+}

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayCoordinator.kt

@@ -6,6 +6,7 @@ import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.Status
 import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.help.book.getBookSource
@@ -38,6 +39,7 @@ class AudioPlayCoordinator(
     private val bookRepository: BookRepository,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val playbackCapsuleGateway: PlaybackCapsuleGateway,
 ) {
     private val refreshRequests = MutableSharedFlow<Unit>(replay = 1)
     private val loading = MutableStateFlow(false)
@@ -164,7 +166,12 @@ class AudioPlayCoordinator(
         }
     }
 
-    fun stop() = AudioPlay.stop()
+    fun prepareBook(bookUrl: String) = playbackCapsuleGateway.prepareAudioBook(bookUrl)
+
+    fun stop() {
+        AudioPlay.book?.bookUrl?.let(playbackCapsuleGateway::clearPreparedAudioBook)
+        AudioPlay.stop()
+    }
 
     fun previous() = AudioPlay.prev()
 

+ 10 - 14
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -24,7 +24,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.Login
@@ -87,7 +86,9 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.SmallAnimatedButton
 import io.legado.app.ui.widget.components.icon.AppIcons
-import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.core.ui.player.PlayerMorphCover
+import io.legado.app.core.ui.player.PlayerMorphAppearance
+import io.legado.app.core.ui.player.TrackPlayerMorphCoverPage
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -120,6 +121,7 @@ fun AudioPlayScreenContent(
         initialPage = 1,
         pageCount = { if (state.lyricLines.isEmpty()) 2 else 3 },
     )
+    TrackPlayerMorphCoverPage(horizontalPagerState)
     var menuExpanded by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
@@ -779,10 +781,6 @@ private fun AudioCoverPage(
             .padding(horizontal = 24.dp),
         horizontalAlignment = Alignment.CenterHorizontally,
     ) {
-        val coverShape = when (state.coverRatio) {
-            CoverRatio.Circle -> CircleShape
-            else -> RoundedCornerShape(8.dp)
-        }
         Box(
             modifier = Modifier.fillMaxWidth(),
             contentAlignment = Alignment.TopCenter,
@@ -800,14 +798,12 @@ private fun AudioCoverPage(
                 else ->
                     Modifier.fillMaxSize(0.64f)
             }
-            BookCoverImage(
-                name = state.bookName,
-                author = state.author,
-                path = state.coverPath,
-                sourceOrigin = state.sourceOrigin,
-                modifier = Modifier
-                    .then(coverModifier)
-                    .clip(coverShape)
+            PlayerMorphCover(
+                appearance = PlayerMorphAppearance(
+                    state.bookName, state.author, state.coverPath, state.sourceOrigin, state.bgMode,
+                ),
+                modifier = coverModifier,
+                circular = state.coverRatio == CoverRatio.Circle,
             )
         }
         Column(

+ 16 - 1
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt

@@ -27,6 +27,7 @@ import io.legado.app.help.config.compatDsInt
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.postEvent
+import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
@@ -52,6 +53,11 @@ class AudioPlayViewModel(
 
     private val activeSheet = MutableStateFlow<AudioPlaySheet?>(null)
 
+    // uiState 的 initialValue 会同步读取缓存,必须先完成缓存初始化。
+    // 歌词仅在切章 / 换曲时变化,避免随每秒 AUDIO_PROGRESS 重复解析整篇 LRC。
+    private var lyricCacheKey: String? = null
+    private var lyricCache: ImmutableList<AudioLyricLine> = persistentListOf()
+
     val uiState = combine(
         coordinator.state,
         AppConfigStore.observeInt(PreferKey.audioPlayBgMode),
@@ -234,6 +240,7 @@ class AudioPlayViewModel(
         } else {
             AudioPlay.resetData(book)
         }
+        coordinator.prepareBook(book.bookUrl)
         if (book.tocUrl.isEmpty() && !loadBookInfo(book)) {
             return
         }
@@ -290,6 +297,14 @@ class AudioPlayViewModel(
         AppConfigStore.preferences.compatDsInt(PreferKey.audioPlayBgMode)
             ?: ReadAloudBgMode.Blur
 
+    private fun lyricLines(lyric: String?): ImmutableList<AudioLyricLine> {
+        if (lyric == lyricCacheKey) return lyricCache
+        return parseAudioLyrics(lyric).also {
+            lyricCacheKey = lyric
+            lyricCache = it
+        }
+    }
+
     private fun toUiState(
         source: AudioPlaySourceState,
         bgMode: Int,
@@ -304,7 +319,7 @@ class AudioPlayViewModel(
         chapterIndex = source.chapterIndex,
         chapterTitle = source.chapterTitle,
         chapters = source.chapters,
-        lyricLines = parseAudioLyrics(source.lyric),
+        lyricLines = lyricLines(source.lyric),
         status = source.status,
         isLoading = source.isLoading,
         position = source.position,

+ 269 - 175
app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt

@@ -1,15 +1,15 @@
 package io.legado.app.ui.book.read
 
-import androidx.compose.animation.AnimatedContent
+import android.os.Build
+import android.util.DisplayMetrics
+import android.view.WindowManager
 import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.snap
 import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.detectDragGestures
@@ -19,54 +19,71 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.ExpandLess
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
+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.clipToBounds
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionOnScreen
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.feature.readaloud.overlay.CapsuleDefaultCenterFromBottomDp
+import io.legado.app.feature.readaloud.overlay.capsuleDockSide
+import io.legado.app.feature.readaloud.overlay.capsulePresentationOffsetX
+import io.legado.app.feature.readaloud.overlay.snapCapsuleOffsetX
+import io.legado.app.ui.book.readaloud.morph.CapsuleAnchorKind
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
+import io.legado.app.ui.book.readaloud.morph.computeMorphPanelAlpha
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
-import top.yukonga.miuix.kmp.basic.VerticalDivider
 import kotlin.time.Duration.Companion.milliseconds
 
 /**
  * 朗读悬浮胶囊。
  *
  * 由宿主 Activity 叠加在所有导航之上(见 `ReadAloudShellHost`),因此只接受展示所需的
- * 原始值,不依赖阅读器 ViewModel;上下边距由宿主按所在界面传入。
+ * 原始值,不依赖阅读器 ViewModel;应用内外共用相对屏幕底部中央的中心坐标。
  */
 @Composable
 fun ReadAloudCapsule(
@@ -79,21 +96,53 @@ fun ReadAloudCapsule(
     offsetYDp: Float,
     progress: Float,
     autoCollapse: Boolean,
-    bottomPadding: Dp,
     onPositionChanged: (xDp: Float, yDp: Float) -> Unit,
     onTogglePause: () -> Unit,
     onStop: () -> Unit,
     onOpenPlayer: () -> Unit,
+    /** 系统窗口由平台宿主管理位置,组件只报告拖拽,不创建 WindowManager。 */
+    onWindowPositionChanged: ((xDp: Float, yDp: Float, expansion: Float) -> Unit)? = null,
 ) {
     val density = LocalDensity.current
+    val hostView = LocalView.current
+    val configuration = LocalConfiguration.current
+    val screenSize = remember(hostView, configuration) {
+        val manager = hostView.context.getSystemService(WindowManager::class.java)
+        if (Build.VERSION.SDK_INT >= 30) {
+            val bounds = manager.maximumWindowMetrics.bounds
+            IntSize(bounds.width(), bounds.height())
+        } else {
+            val metrics = DisplayMetrics()
+            @Suppress("DEPRECATION")
+            manager.defaultDisplay.getRealMetrics(metrics)
+            IntSize(metrics.widthPixels, metrics.heightPixels)
+        }
+    }
+    var containerSize by remember { mutableStateOf(IntSize.Zero) }
+    var containerScreenOffset by remember { mutableStateOf(Offset.Zero) }
+    var capsuleSize by remember { mutableStateOf(IntSize.Zero) }
     val currentOnPositionChanged by rememberUpdatedState(onPositionChanged)
+    val morph = LocalReadAloudMorph.current
+    SideEffect { morph?.reportProgressRing(null) }
+    val showCapsuleShadow by remember(morph) {
+        derivedStateOf { (morph?.progress?.value ?: 0f) <= 0f }
+    }
+    // 开始形变立即关闭;完全收回后只恢复阴影强度,不改变胶囊几何。
+    val restoredShadow by animateFloatAsState(
+        targetValue = if (showCapsuleShadow) 1f else 0f,
+        animationSpec = if (showCapsuleShadow) tween(180) else snap(),
+        label = "capsuleShadowRestore",
+    )
+    val shadowStrength = if (showCapsuleShadow) restoredShadow else 0f
+    // 胶囊脸色要上报给形变宿主:面板从胶囊长出来时,进度 0 必须是同一个颜色。
+    val capsuleFaceColor = capsuleSurfaceColor()
 
     val coverRotation = remember { Animatable(0f) }
     LaunchedEffect(isPaused) {
         while (!isPaused && isActive) {
             coverRotation.animateTo(
                 targetValue = coverRotation.value + 360f,
-                animationSpec = tween(durationMillis = 12_000, easing = LinearEasing),
+                animationSpec = tween(durationMillis = 20_000, easing = LinearEasing),
             )
         }
     }
@@ -109,12 +158,36 @@ fun ReadAloudCapsule(
         offsetY = with(density) { Dp(offsetYDp).toPx() }
     }
 
-    var collapsed by remember { mutableStateOf(false) }
-    var touchTimestamp by remember { mutableFloatStateOf(0f) }
+    val morphActive by remember(morph) {
+        derivedStateOf { (morph?.progress?.value ?: 0f) > 0f }
+    }
+    val screenWidthDp = screenSize.width / density.density
+    var collapsed by remember { mutableStateOf(capsuleDockSide(offsetXDp, screenWidthDp) != 0) }
+    var dragging by remember { mutableStateOf(false) }
+    var touchTimestamp by remember { mutableIntStateOf(0) }
+    val settledOffsetX by animateFloatAsState(
+        targetValue = offsetX,
+        animationSpec = if (dragging) snap() else tween(220),
+        label = "capsuleEdgeSnap",
+    )
+    // 封面始终是同一个节点;宽度、按钮揭示和贴边位置共用一个连续进度。
+    val expansion by animateFloatAsState(
+        targetValue = if (collapsed) 0f else 1f,
+        animationSpec = tween(320, easing = FastOutSlowInEasing),
+        label = "capsuleExpansion",
+    )
+    val currentWindowPositionChanged by rememberUpdatedState(onWindowPositionChanged)
+    LaunchedEffect(settledOffsetX, offsetY, density, expansion) {
+        currentWindowPositionChanged?.invoke(
+            settledOffsetX / density.density,
+            offsetY / density.density,
+            expansion
+        )
+    }
 
     // Auto-collapse timer
-    LaunchedEffect(autoCollapse, collapsed) {
-        if (autoCollapse && !collapsed) {
+    LaunchedEffect(autoCollapse, collapsed, morphActive, dragging, touchTimestamp) {
+        if (autoCollapse && !collapsed && !morphActive && !dragging) {
             delay(3000.milliseconds)
             collapsed = true
         }
@@ -122,46 +195,90 @@ fun ReadAloudCapsule(
 
     // Reset collapse on any touch
     fun onTouched() {
-        touchTimestamp = System.currentTimeMillis().toFloat()
-        if (collapsed) {
-            collapsed = false
-        }
+        touchTimestamp++
     }
 
-    val capsuleHeight by animateDpAsState(
-        targetValue = if (collapsed) 16.dp else 48.dp,
-        animationSpec = tween(durationMillis = 250),
-        label = "capsuleHeight",
-    )
-
-    val cornerRadius by animateDpAsState(
-        targetValue = if (collapsed) 8.dp else 28.dp,
-        animationSpec = tween(durationMillis = 250),
-        label = "cornerRadius",
-    )
+    val cornerRadius = (24f + 4f * expansion).dp
 
     Box(
-        modifier = Modifier.fillMaxSize(),
+        modifier = if (onWindowPositionChanged == null) Modifier
+            .fillMaxSize()
+            .onGloballyPositioned {
+                containerSize = it.size
+                containerScreenOffset = it.positionOnScreen()
+            } else Modifier
+            .wrapContentSize()
+            .padding(16.dp),
     ) {
         Surface(
             modifier = Modifier
-                .align(Alignment.BottomCenter)
-                .navigationBarsPadding()
-                .padding(bottom = bottomPadding)
-                .offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
-                .pointerInput(density) {
+                .align(if (onWindowPositionChanged == null) Alignment.BottomCenter else Alignment.TopStart)
+                .then(if (onWindowPositionChanged == null) Modifier.layout { measurable, constraints ->
+                    val placeable = measurable.measure(constraints)
+                    layout(placeable.width, placeable.height) {
+                        // 实际测量高度参与定位,折叠、字体缩放不改变屏幕中心。
+                        placeable.place(
+                            (screenSize.width / 2f - containerScreenOffset.x - containerSize.width / 2f +
+                                    capsulePresentationOffsetX(
+                                        settledOffsetX / density.density, screenWidthDp,
+                                        placeable.width / density.density + 32f, expansion
+                                    ) * density.density).toInt(),
+                            (screenSize.height - containerScreenOffset.y - containerSize.height -
+                                    CapsuleDefaultCenterFromBottomDp * density.density + placeable.height / 2f + offsetY).toInt(),
+                        )
+                    }
+                } else Modifier)
+                // 定位必须在透明度图层之外:alpha < 1 会建立离屏缓冲,
+                // 若在图层内部移动内容,背景会被原位置的小缓冲区裁掉。
+                .graphicsLayer { alpha = 1f - computeMorphPanelAlpha(morph?.progress?.value ?: 0f) }
+                .onGloballyPositioned { coordinates ->
+                    capsuleSize = coordinates.size
+                    morph?.reportPanelStart(
+                        bounds = coordinates.boundsInRoot(),
+                        anchorKind = CapsuleAnchorKind.Global,
+                        cornerRadiusPx = with(density) { cornerRadius.toPx() },
+                        surfaceColor = capsuleFaceColor,
+                    )
+                }
+                .pointerInput(density, screenSize) {
                     detectDragGestures(
+                        onDragStart = {
+                            offsetX = capsulePresentationOffsetX(
+                                settledOffsetX / density.density, screenWidthDp,
+                                capsuleSize.width / density.density + 32f, expansion
+                            ) * density.density
+                            dragging = true
+                            onTouched()
+                        },
+                        onDragCancel = { dragging = false; onTouched() },
                         onDragEnd = {
+                            offsetX = snapCapsuleOffsetX(
+                                offsetX / density.density,
+                                screenWidthDp
+                            ) * density.density
+                            if (capsuleDockSide(
+                                    offsetX / density.density,
+                                    screenWidthDp
+                                ) != 0
+                            ) collapsed = true
+                            dragging = false
+                            onTouched()
                             currentOnPositionChanged(
                                 offsetX / density.density,
-                                offsetY / density.density,
+                                offsetY / density.density
                             )
                         },
                     ) { change, dragAmount ->
                         change.consume()
-                        offsetX += dragAmount.x
-                        offsetY += dragAmount.y
-                        onTouched()
+                        // 中心允许拖到屏幕边线,松手后才吸附;不会在拖动迷你封面时展开。
+                        val halfWidth = screenSize.width / 2f
+                        val halfHeight = capsuleSize.height / 2f + 16f * density.density
+                        val base = CapsuleDefaultCenterFromBottomDp * density.density
+                        offsetX = (offsetX + dragAmount.x).coerceIn(-halfWidth, halfWidth)
+                        offsetY = (offsetY + dragAmount.y).coerceIn(
+                            (base - screenSize.height + halfHeight).coerceAtMost(base - halfHeight),
+                            base - halfHeight,
+                        )
                     }
                 }
                 .pointerInput(Unit) {
@@ -172,40 +289,28 @@ fun ReadAloudCapsule(
             shape = RoundedCornerShape(cornerRadius),
             // 胶囊走极简日夜配色:日间纯白 + 深色内容,夜间纯黑 + 浅色内容。
             // 不跟随主题取色,避免在阅读页/听书页的背景之上忽明忽暗。
-            color = capsuleSurfaceColor(),
+            color = capsuleFaceColor.copy(alpha = expansion),
             contentColor = capsuleContentColor(),
             tonalElevation = 0.dp,
-            shadowElevation = 12.dp,
+            // 形变期间不留原位投影,完全收回后才恢复。
+            shadowElevation = 12.dp * shadowStrength * expansion,
         ) {
-            AnimatedContent(
-                targetState = collapsed,
-                transitionSpec = {
-                    fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.9f) togetherWith
-                            fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.9f)
+            CapsuleContent(
+                bookName = bookName,
+                author = author,
+                coverPath = coverPath,
+                sourceOrigin = sourceOrigin,
+                isPaused = isPaused,
+                progress = progress,
+                coverRotation = coverRotation,
+                expansion = expansion,
+                onTogglePause = { onTouched(); onTogglePause() },
+                onStop = onStop,
+                onCoverClick = {
+                    onTouched()
+                    if (collapsed || expansion < 1f) collapsed = false else onOpenPlayer()
                 },
-                label = "capsuleContent",
-            ) { isCollapsed ->
-                if (isCollapsed) {
-                    CollapsedCapsuleContent(
-                        isPaused = isPaused,
-                        onTogglePause = onTogglePause,
-                        onExpand = { collapsed = false },
-                    )
-                } else {
-                    ExpandedCapsuleContent(
-                        bookName = bookName,
-                        author = author,
-                        coverPath = coverPath,
-                        sourceOrigin = sourceOrigin,
-                        isPaused = isPaused,
-                        progress = progress,
-                        coverRotation = coverRotation.value,
-                        onTogglePause = onTogglePause,
-                        onStop = onStop,
-                        onOpenPlayer = onOpenPlayer,
-                    )
-                }
-            }
+            )
         }
     }
 }
@@ -231,80 +336,35 @@ private fun capsuleMutedColor(): Color =
     if (LegadoTheme.isDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
 
 @Composable
-private fun CollapsedCapsuleContent(
-    isPaused: Boolean,
-    onTogglePause: () -> Unit,
-    onExpand: () -> Unit,
-) {
-    val contentColor = capsuleContentColor()
-    Row(
-        verticalAlignment = Alignment.CenterVertically,
-        horizontalArrangement = Arrangement.Center,
-    ) {
-        Row(
-            modifier = Modifier
-                .clickable(onClick = onTogglePause)
-                .padding(all = 8.dp),
-        ) {
-            Icon(
-                imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
-                contentDescription = stringResource(
-                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
-                ),
-                modifier = Modifier
-                    .size(16.dp),
-                tint = contentColor,
-            )
-            Text(
-                text = stringResource(
-                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
-                ),
-                style = LegadoTheme.typography.labelSmall,
-                color = contentColor,
-                modifier = Modifier.padding(start = 2.dp),
-            )
-        }
-
-        VerticalDivider(
-            modifier = Modifier
-                .padding(start = 4.dp)
-                .height(10.dp)
-                .width(1.dp),
-            color = capsuleMutedColor(),
-        )
-
-        Icon(
-            imageVector = Icons.Default.ExpandLess,
-            contentDescription = stringResource(R.string.expand),
-            modifier = Modifier
-                .clickable(onClick = onExpand)
-                .padding(horizontal = 8.dp, vertical = 6.dp)
-                .size(16.dp),
-            tint = contentColor,
-        )
-    }
-}
-
-@Composable
-private fun ExpandedCapsuleContent(
+private fun CapsuleContent(
     bookName: String?,
     author: String?,
     coverPath: String?,
     sourceOrigin: String?,
     isPaused: Boolean,
     progress: Float,
-    coverRotation: Float,
+    coverRotation: Animatable<Float, AnimationVector1D>,
+    expansion: Float,
     onTogglePause: () -> Unit,
     onStop: () -> Unit,
-    onOpenPlayer: () -> Unit,
+    onCoverClick: () -> Unit,
 ) {
+    val coverSize = (48f - 8f * expansion).dp
+    val expandDescription = stringResource(R.string.expand)
     Row(
-        modifier = Modifier.padding(all = 4.dp),
-        horizontalArrangement = Arrangement.spacedBy(4.dp),
+        modifier = Modifier.padding(all = (4f * expansion).dp),
+        horizontalArrangement = Arrangement.spacedBy((4f * expansion).dp),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         val contentColor = capsuleContentColor()
         val mutedColor = capsuleMutedColor()
+        val coverMorph = LocalReadAloudMorph.current
+        val coverDensity = LocalDensity.current
+        // 旋转不改变布局;角度不能只依赖 onGloballyPositioned 更新。
+        LaunchedEffect(coverMorph, coverRotation) {
+            val target = coverMorph ?: return@LaunchedEffect
+            snapshotFlow { coverRotation.value }.collect { target.reportCoverRotation(it) }
+        }
         BookCoverImage(
             name = bookName,
             author = author,
@@ -313,51 +373,85 @@ private fun ExpandedCapsuleContent(
             // 听书胶囊也是书维度场景,本地优先不跑书源脚本
             bookUrl = null,
             preferCache = true,
+            showLoadingPlaceholder = false,
+            requestBuilder = { size(coil3.size.Size(1024, 1024)) },
             modifier = Modifier
-                .size(40.dp)
-                .graphicsLayer { rotationZ = coverRotation }
+                .size(coverSize)
+                // 锚点要在旋转之前上报,否则旋转后的包围盒会让形变起点跳动。
+                .onGloballyPositioned { coordinates ->
+                    coverMorph?.reportCoverStart(
+                        bounds = coordinates.boundsInRoot(),
+                        anchorKind = CapsuleAnchorKind.Global,
+                        cornerRadiusPx = with(coverDensity) { coverSize.toPx() / 2f },
+                        rotationDeg = coverRotation.value,
+                    )
+                }
+                .graphicsLayer {
+                    rotationZ = coverRotation.value
+                    alpha = if ((coverMorph?.progress?.value ?: 0f) <= 0f) 1f else 0f
+                }
                 .clip(CircleShape)
-                .clickable(onClick = onOpenPlayer),
+                .then(if (expansion < 1f) Modifier.semantics {
+                    contentDescription = expandDescription
+                } else Modifier)
+                .clickable(onClick = onCoverClick),
         )
+        // 按钮始终以完整尺寸测量,仅连续揭示宽度;不切换两套封面或压缩按钮。
         Box(
-            modifier = Modifier
-                .size(40.dp)
-                .clip(CircleShape)
-                // 黑白配色下用弱化的中性底,不再用主题的 secondaryContainer
-                .background(mutedColor.copy(alpha = 0.1f))
-                .clickable(onClick = onTogglePause),
-            contentAlignment = Alignment.Center,
-        ) {
-            Icon(
-                imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
-                contentDescription = stringResource(
-                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
-                ),
-                modifier = Modifier.size(24.dp),
-                tint = contentColor,
-            )
-        }
-        Box(
-            modifier = Modifier
-                .size(40.dp),
-            contentAlignment = Alignment.Center,
+            Modifier
+                .width((84f * expansion).dp)
+                .height(40.dp)
+                .clipToBounds()
+                .graphicsLayer { alpha = expansion },
         ) {
-            CircularProgressIndicator(
-                modifier = Modifier.fillMaxSize(),
-                progress = { progress.coerceIn(0f, 1f) },
-                strokeWidth = 2.dp,
-                color = mutedColor,
-                trackColor = mutedColor.copy(alpha = 0.25f),
-            )
-            Icon(
-                imageVector = Icons.Default.Close,
-                contentDescription = stringResource(R.string.stop_read_aloud),
-                modifier = Modifier
-                    .size(24.dp)
-                    .clip(CircleShape)
-                    .clickable(onClick = onStop),
-                tint = contentColor,
-            )
+            Row(
+                Modifier
+                    .wrapContentSize(Alignment.CenterStart, unbounded = true)
+                    .width(84.dp),
+                horizontalArrangement = Arrangement.spacedBy(4.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Box(
+                    modifier = Modifier
+                        .size(40.dp)
+                        .clip(CircleShape)
+                        // 黑白配色下用弱化的中性底,不再用主题的 secondaryContainer
+                        .background(mutedColor.copy(alpha = 0.1f))
+                        .clickable(enabled = expansion >= 1f, onClick = onTogglePause),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    Icon(
+                        imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+                        contentDescription = stringResource(
+                            if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
+                        ),
+                        modifier = Modifier.size(24.dp),
+                        tint = contentColor,
+                    )
+                }
+                Box(
+                    modifier = Modifier
+                        .size(40.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    CircularProgressIndicator(
+                        modifier = Modifier.fillMaxSize(),
+                        progress = { progress.coerceIn(0f, 1f) },
+                        strokeWidth = 2.dp,
+                        color = mutedColor,
+                        trackColor = mutedColor.copy(alpha = 0.25f),
+                    )
+                    Icon(
+                        imageVector = Icons.Default.Close,
+                        contentDescription = stringResource(R.string.stop_read_aloud),
+                        modifier = Modifier
+                            .size(24.dp)
+                            .clip(CircleShape)
+                            .clickable(enabled = expansion >= 1f, onClick = onStop),
+                        tint = contentColor,
+                    )
+                }
+            }
         }
     }
 }

+ 6 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -24,6 +24,7 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.service.BaseReadAloudService
+import io.legado.app.ui.book.readaloud.ReadAloudPlayerOverlayBus
 import io.legado.app.utils.TTSCacheUtils
 import io.legado.app.utils.postEvent
 import kotlinx.collections.immutable.toImmutableSet
@@ -222,14 +223,15 @@ class ReadAloudDelegate(
     }
 
     /**
-     * 打开听书播放界面。
+     * 打开听书播放弹层。
      *
-     * 播放界面是 Navigation 3 目的地而非阅读器弹层,所以这里发导航意图;
-     * 先把菜单状态收起来,返回阅读界面时不会停在半开的菜单上。
+     * 播放弹层是 Activity 级全局浮层([ReadAloudPlayerOverlayBus]),不占导航栈,
+     * 所以这里只请求宿主把它拉起来;先把菜单与已有弹层收掉,
+     * 关闭播放弹层时不会停在半开的菜单上。
      */
     fun openPlayer() {
         host.updateState { it.copy(menuState = ReadBookMenuState(), activeSheet = null) }
-        host.emitEffect(ReadBookEffect.OpenReadAloudPlayer)
+        ReadAloudPlayerOverlayBus.request()
     }
 
     /** 经典朗读控制面板:阅读菜单里的一页,不遮挡正文区域之外的交互。 */

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

@@ -985,14 +985,6 @@ sealed interface ReadBookEffect {
     data class OpenMenuCustomIconPicker(val id: String) : ReadBookEffect
     data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookEffect
     data object OpenSystemTtsSettings : ReadBookEffect
-
-    /**
-     * 打开听书播放界面。
-     *
-     * 播放界面是 Navigation 3 目的地([io.legado.app.ui.main.MainRouteReadAloudPlayer]),
-     * 不再是阅读器内的弹层,因此这里只发导航意图,不写 `activeSheet`。
-     */
-    data object OpenReadAloudPlayer : ReadBookEffect
     data object OpenTtsEnginesAndVoices : ReadBookEffect
     data object OpenTtsCache : ReadBookEffect
     data class OpenBookVoiceCasting(val bookUrl: String) : ReadBookEffect

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

@@ -2404,8 +2404,6 @@ class ReadBookController(
             is ReadBookEffect.OpenSystemTtsSettings,
             ReadBookEffect.OpenTtsEnginesAndVoices,
             ReadBookEffect.OpenTtsCache,
-                // 朗读播放界面是 Navigation 3 目的地,由路由层导航,这里不处理
-            ReadBookEffect.OpenReadAloudPlayer,
             is ReadBookEffect.OpenBookVoiceCasting,
             is ReadBookEffect.OpenHighlightRuleImportPicker,
             is ReadBookEffect.OpenHighlightRuleExportPicker,

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

@@ -158,7 +158,6 @@ fun ReadBookRouteScreen(
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
     onOpenTtsCache: () -> Unit = {},
-    onOpenReadAloudPlayer: () -> Unit = {},
 ) {
     // 归因定界:与末尾 compose.screen.end 成对。若首帧 `Compose:recompose` 里出现
     // begin 之前的空档,说明那部分耗时在本屏之外(导航宿主 / 共享转场层)。
@@ -427,7 +426,6 @@ fun ReadBookRouteScreen(
                             }
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
                             ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
-                            ReadBookEffect.OpenReadAloudPlayer -> onOpenReadAloudPlayer()
                             is ReadBookEffect.MenuSettingReplace -> {
                                 replaceLauncher.launch(
                                     ReplaceRuleActivity.startIntent(

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

@@ -499,8 +499,8 @@ fun ReadBookScreen(
     val aloudPlayerViewModel: ReadAloudPlayerViewModel =
         org.koin.compose.koinInject()
     val aloudPlayerShellState by aloudPlayerViewModel.uiState.collectAsStateWithLifecycle()
-    // 听书播放界面已是 Navigation 3 目的地(见 ReadAloudPlayerRouteScreen),
-    // 这里只保留朗读配置卡片;经典控制面板与听书播放界面共用同一份配置内容。
+    // 听书播放页是 Activity 级 morph 浮层(见 ReadAloudPlayerMorphHost),阅读器内
+    // 只保留经典控制面板自己的朗读配置卡片;两者共用同一份配置内容。
     AppModalBottomSheet(
         show = state.activeSheet is ReadBookSheet.ReadAloudConfig,
         onDismissRequest = dismissSheet,

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

@@ -23,6 +23,7 @@ import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.readaloud.ReadAloudContentSplitSetting
 import io.legado.app.domain.model.readaloud.ReadAloudSplitSymbol
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+import io.legado.app.feature.readaloud.overlay.ReadAloudOverlayPermissionRoute
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
@@ -118,6 +119,7 @@ fun ReadAloudConfigContent(
                         },
                     )
                     if (state.showReadAloudCapsule) {
+                        ReadAloudOverlayPermissionRoute()
                         TinySwitchSettingItem(
                             title = stringResource(R.string.capsule_auto_collapse),
                             description = stringResource(R.string.capsule_auto_collapse_summary),

+ 448 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudBarCapsule.kt

@@ -0,0 +1,448 @@
+package io.legado.app.ui.book.readaloud
+
+import android.os.Build
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.sp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.text.AppText
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.blur
+import com.kyant.backdrop.effects.lens
+import com.kyant.backdrop.effects.vibrancy
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.Shadow
+import com.kyant.capsule.ContinuousCapsule
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleState
+import io.legado.app.ui.book.readaloud.morph.CapsuleProgressRing
+import io.legado.app.ui.book.readaloud.morph.CapsuleAnchorKind
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LocalAppUiConfiguration
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import org.koin.compose.koinInject
+
+/** 与主导航悬浮底栏同高,并且是正圆。 */
+val ReadAloudBarCapsuleSize = 64.dp
+
+/** 与悬浮底栏一致的外边距。 */
+val ReadAloudBarCapsuleEndPadding = 16.dp
+
+private val CapsuleCoverSize = 56.dp
+private val CapsuleRingWidth = 2.dp
+
+/**
+ * 主页悬浮底栏用的听书胶囊:与悬浮底栏同高的**圆形封面按钮**。
+ *
+ * 短按打开播放页;长按与主页导航联动展开章节、进度和播放控制。
+ * 液态玻璃与悬浮底栏共用同一套 `Backdrop`,所以底栏开玻璃时它也跟着开。
+ *
+ * 它同时是「胶囊 → 播放页」形变的起点:把自身矩形与封面矩形上报给
+ * [ReadAloudMorphState],收起时封面回到这里。
+ */
+@Composable
+fun ReadAloudBarCapsule(
+    bookName: String?,
+    author: String?,
+    coverPath: String?,
+    sourceOrigin: String?,
+    progress: Float,
+    onOpenPlayer: () -> Unit,
+    onLongPress: () -> Unit = {},
+    onTogglePause: () -> Unit = {},
+    onStop: () -> Unit = {},
+    chapterTitle: String = "",
+    chapterIndex: Int = -1,
+    isPaused: Boolean = true,
+    expansion: Float = 0f,
+    controlsExpanded: Boolean = false,
+    width: Dp = ReadAloudBarCapsuleSize,
+    expandedWidth: Dp = ReadAloudBarCapsuleSize,
+    modifier: Modifier = Modifier,
+    backdrop: Backdrop? = null,
+    isBlurEnabled: Boolean = false,
+) {
+    val morph = LocalReadAloudMorph.current
+    val currentMorph by rememberUpdatedState(morph)
+    val showCapsuleShadow by remember(morph) {
+        derivedStateOf { (morph?.progress?.value ?: 0f) <= 0f }
+    }
+    // 开始形变立即关闭;完全收回后只恢复阴影强度,不改变胶囊几何。
+    val restoredShadow by animateFloatAsState(
+        targetValue = if (showCapsuleShadow) 1f else 0f,
+        animationSpec = if (showCapsuleShadow) tween(180) else snap(),
+        label = "capsuleShadowRestore",
+    )
+    val shadowStrength = if (showCapsuleShadow) restoredShadow else 0f
+    // 展开动画进行中胶囊已被淡出,不能再抢点击。
+    val idle by remember(morph) { derivedStateOf { (morph?.progress?.value ?: 0f) <= 0.001f } }
+    val themeSettings = LocalAppUiConfiguration.current.theme
+    val isDark = LegadoTheme.isDark
+    val ringColor = LegadoTheme.colorScheme.primary
+    val ringDensity = LocalDensity.current
+    SideEffect {
+        morph?.reportProgressRing(
+            CapsuleProgressRing(
+                progress,
+                ringColor,
+                with(ringDensity) { CapsuleRingWidth.toPx() })
+        )
+    }
+    val containerColor = if (isBlurEnabled) {
+        LegadoTheme.colorScheme.surfaceContainer.copy(
+            alpha = themeSettings.bottomBarBlurAlpha / 100f
+        )
+    } else {
+        LegadoTheme.colorScheme.surfaceContainer
+    }
+    val glass = isBlurEnabled &&
+            backdrop != null &&
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+
+    val surfaceModifier = if (glass) {
+        Modifier.drawBackdrop(
+            backdrop = backdrop,
+            shape = { ContinuousCapsule },
+            effects = {
+                vibrancy()
+                blur(themeSettings.bottomBarBlurRadius.toFloat().dp.toPx())
+                lens(
+                    themeSettings.bottomBarLensRadius.dp.toPx(),
+                    themeSettings.bottomBarLensRadius.dp.toPx(),
+                )
+            },
+            highlight = { Highlight.Default },
+            shadow = if (showCapsuleShadow) {
+                { Shadow.Default.copy(color = Color.Black.copy(alpha = (if (isDark) 0.2f else 0.1f) * shadowStrength)) }
+            } else null,
+            onDrawSurface = { drawRect(containerColor) },
+        )
+    } else {
+        Modifier
+            .shadow(
+                elevation = 12.dp * shadowStrength,
+                shape = ContinuousCapsule,
+                clip = false,
+                ambientColor = Color.Black.copy(alpha = 0.18f),
+                spotColor = Color.Black.copy(alpha = 0.18f),
+            )
+            .clip(ContinuousCapsule)
+            .background(containerColor)
+    }
+
+    Box(
+        modifier = modifier
+            .width(width)
+            .height(ReadAloudBarCapsuleSize)
+            .graphicsLayer { alpha = 1f - (morph?.progress?.value ?: 0f) }
+            .then(surfaceModifier)
+            .clip(ContinuousCapsule)
+            .onGloballyPositioned { coordinates ->
+                val state = currentMorph ?: return@onGloballyPositioned
+                state.reportPanelStart(
+                    bounds = coordinates.boundsInRoot(),
+                    anchorKind = CapsuleAnchorKind.HomeBar,
+                    cornerRadiusPx = coordinates.size.height / 2f,
+                    // 底栏式胶囊的脸色(玻璃开启时带透明度):面板起点要与它一致。
+                    surfaceColor = containerColor,
+                )
+            }
+            .combinedClickable(
+                enabled = idle,
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = onOpenPlayer,
+                onLongClick = onLongPress,
+                onLongClickLabel = stringResource(
+                    if (controlsExpanded) R.string.home_capsule_restore_navigation
+                    else R.string.home_capsule_expand_controls
+                ),
+            ),
+        contentAlignment = Alignment.CenterStart,
+    ) {
+        Box(
+            modifier = Modifier
+                .padding(start = 4.dp)
+                .size(CapsuleCoverSize)
+        ) {
+            BookCoverImage(
+                name = bookName,
+                author = author,
+                path = coverPath,
+                sourceOrigin = sourceOrigin,
+                // 听书胶囊是书维度场景,本地优先不跑书源脚本
+                bookUrl = null,
+                preferCache = true,
+                showLoadingPlaceholder = morph == null,
+                requestBuilder = { if (morph != null) size(coil3.size.Size(1024, 1024)) },
+                modifier = Modifier
+                    .fillMaxSize()
+                    // 锚点要在形变之前上报真实矩形。
+                    .onGloballyPositioned { coordinates ->
+                        val state = currentMorph ?: return@onGloballyPositioned
+                        state.reportCoverStart(
+                            bounds = coordinates.boundsInRoot(),
+                            anchorKind = CapsuleAnchorKind.HomeBar,
+                            cornerRadiusPx = coordinates.size.height / 2f,
+                        )
+                    }
+                    .clip(CircleShape)
+                    .graphicsLayer {
+                        // 圆形端也只在完全收回后绘制,避免飞行封面与源封面叠影。
+                        alpha = if ((morph?.progress?.value ?: 0f) <= 0f) 1f else 0f
+                    },
+            )
+            if (progress > 0f) {
+                Box(
+                    modifier = Modifier
+                        .matchParentSize()
+                        .graphicsLayer {
+                            alpha = if ((morph?.progress?.value ?: 0f) <= 0f) 1f else 0f
+                        }
+                        .drawBehind {
+                            val stroke = CapsuleRingWidth.toPx()
+                            val inset = stroke / 2f
+                            drawArc(
+                                color = ringColor,
+                                startAngle = -90f,
+                                sweepAngle = 360f * progress.coerceIn(0f, 1f),
+                                useCenter = false,
+                                topLeft = Offset(inset, inset),
+                                size = Size(size.width - stroke, size.height - stroke),
+                                style = Stroke(width = stroke, cap = StrokeCap.Round),
+                            )
+                        },
+                )
+            }
+        }
+        val detailsAlpha = ((expansion - 0.2f) / 0.8f).coerceIn(0f, 1f)
+        val controlsEnabled = idle && expansion >= 0.99f
+        Row(
+            modifier = Modifier
+                .padding(start = ReadAloudBarCapsuleSize)
+                .wrapContentWidth(Alignment.Start, unbounded = true)
+                .requiredWidth((expandedWidth - ReadAloudBarCapsuleSize).coerceAtLeast(0.dp))
+                .fillMaxHeight()
+                .graphicsLayer { alpha = detailsAlpha }
+                .then(if (!controlsEnabled) Modifier.clearAndSetSemantics {} else Modifier)
+                .padding(end = 4.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Column(
+                Modifier
+                    .weight(1f)
+                    .padding(vertical = 5.dp, horizontal = 2.dp),
+                verticalArrangement = Arrangement.spacedBy(2.dp),
+            ) {
+                AppText(
+                    text = bookName.orEmpty(),
+                    modifier = Modifier.basicMarquee(
+                        iterations = 3,
+                        repeatDelayMillis = 3000,
+                        initialDelayMillis = 3000,
+                        velocity = 16.dp
+                    ),
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                    maxLines = 1, overflow = TextOverflow.Ellipsis
+                )
+                val chapter = chapterTitle.ifBlank {
+                    if (chapterIndex >= 0) stringResource(
+                        R.string.home_capsule_chapter,
+                        chapterIndex + 1
+                    )
+                    else stringResource(R.string.no_chapter)
+                }
+                AppText(
+                    text = chapter,
+                    modifier = Modifier.basicMarquee(
+                        iterations = 3,
+                        repeatDelayMillis = 3000,
+                        initialDelayMillis = 3000,
+                        velocity = 16.dp
+                    ),
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis
+                )
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.spacedBy(4.dp)
+                ) {
+                    LinearProgressIndicator(
+                        progress = { progress.coerceIn(0f, 1f) },
+                        modifier = Modifier
+                            .weight(1f)
+                            .height(2.dp),
+                        color = ringColor,
+                        trackColor = LegadoTheme.colorScheme.surfaceContainerHighest,
+                        gapSize = 0.dp,
+                        drawStopIndicator = {}
+                    )
+                }
+            }
+            MediumPlainButton(
+                onClick = onTogglePause,
+                enabled = controlsEnabled,
+                icon = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+                contentDescription = stringResource(if (isPaused) R.string.resume else R.string.pause)
+            )
+            MediumPlainButton(
+                onClick = onStop,
+                enabled = controlsEnabled,
+                icon = Icons.Default.Close,
+                contentDescription = stringResource(R.string.exit)
+            )
+        }
+    }
+}
+
+/**
+ * 胶囊的自包含入口:自己取朗读会话与设置判断显隐,隐藏时占位为零。
+ *
+ * 隐藏时保持零尺寸是刻意的:宿主把它和悬浮底栏放在同一行时,
+ * 底栏能按剩余宽度自动让位(见 `MainScreen` 的悬浮底栏分支)。
+ */
+@Composable
+fun ReadAloudBarCapsuleSlot(
+    enabled: Boolean,
+    onOpenPlayer: (PlaybackCapsuleState) -> Unit,
+    morph: ReadAloudMorphState?,
+    anchorPreview: PlaybackCapsuleState? = null,
+    controlsExpanded: Boolean = false,
+    expansion: Float = 0f,
+    width: Dp = ReadAloudBarCapsuleSize,
+    expandedWidth: Dp = ReadAloudBarCapsuleSize,
+    onControlsExpandedChange: (Boolean) -> Unit = {},
+    modifier: Modifier = Modifier,
+    backdrop: Backdrop? = null,
+    isBlurEnabled: Boolean = false,
+    settingsGateway: ReadAloudSettingsGateway = koinInject(),
+    playbackGateway: PlaybackCapsuleGateway = koinInject(),
+    /** 只上报胶囊内容宽度;退场完成或宿主移除时归零,不包含宿主外边距。 */
+    onOccupiedWidthChanged: (Int) -> Unit = {},
+) {
+    val currentOnOccupiedWidthChanged by rememberUpdatedState(onOccupiedWidthChanged)
+    val settings by settingsGateway.settings.collectAsStateWithLifecycle(
+        settingsGateway.currentSettings
+    )
+    val playerState by playbackGateway.state.collectAsStateWithLifecycle()
+
+    val previewOnly = playerState.source == null
+    val displayState = if (previewOnly) anchorPreview ?: playerState else playerState
+    val visible = enabled && settings.showReadAloudCapsule &&
+            (playerState.source != null || anchorPreview != null)
+    LaunchedEffect(visible, previewOnly, expandedWidth, playerState.source, playerState.bookUrl) {
+        if (!visible || previewOnly || expandedWidth < 160.dp) onControlsExpandedChange(false)
+    }
+    // 不可见时不提供锚点:退场动画期间胶囊仍在组合、仍会上报矩形,
+    // 不掐断就会把「已经离开的胶囊」的位置和脸色写成下一次形变的起点。
+    CompositionLocalProvider(LocalReadAloudMorph provides morph?.takeIf { visible }) {
+        AnimatedVisibility(
+            visible = visible,
+            modifier = if (previewOnly) modifier
+                .graphicsLayer { alpha = 0f }
+                .clearAndSetSemantics { } else modifier,
+            enter = fadeIn(tween(180)),
+            exit = fadeOut(tween(140)),
+        ) {
+            // AnimatedVisibility 退场后会移除内容,不保证再给宿主一次零尺寸布局回调。
+            // 让占位随内容生命周期释放,淡出期间仍为可见胶囊保留空间。
+            DisposableEffect(Unit) {
+                onDispose { currentOnOccupiedWidthChanged(0) }
+            }
+            ReadAloudBarCapsule(
+                bookName = displayState.bookName,
+                author = displayState.author,
+                coverPath = displayState.coverPath,
+                sourceOrigin = displayState.sourceOrigin,
+                progress = displayState.progress,
+                onOpenPlayer = { if (!previewOnly) onOpenPlayer(playerState) },
+                onLongPress = {
+                    if (!previewOnly && (controlsExpanded || expandedWidth >= 160.dp)) {
+                        onControlsExpandedChange(!controlsExpanded)
+                    }
+                },
+                onTogglePause = { playerState.source?.let(playbackGateway::togglePause) },
+                onStop = {
+                    onControlsExpandedChange(false)
+                    playerState.source?.let(playbackGateway::stop)
+                },
+                chapterTitle = displayState.chapterTitle,
+                chapterIndex = displayState.chapterIndex,
+                isPaused = displayState.isPaused,
+                expansion = expansion,
+                controlsExpanded = controlsExpanded,
+                width = width,
+                expandedWidth = expandedWidth,
+                modifier = Modifier.onSizeChanged { currentOnOccupiedWidthChanged(it.width) },
+                backdrop = backdrop,
+                isBlurEnabled = isBlurEnabled,
+            )
+        }
+    }
+}

+ 90 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudPlayerMorphHost.kt

@@ -0,0 +1,90 @@
+package io.legado.app.ui.book.readaloud
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.core.ui.player.PlayerMorphAppearance
+import io.legado.app.core.ui.player.PlayerMorphHost
+import io.legado.app.ui.book.read.sheet.ReadAloudConfigContent
+import io.legado.app.ui.book.read.sheet.asReadBookUiState
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerEffect
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerScreenContent
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerUiState
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
+import io.legado.app.ui.book.readaloud.player.applyReadBookConfigIntent
+import io.legado.app.ui.book.readaloud.player.rememberPlayerThemeOverride
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+
+/** 朗读特有的设置与经典控制;几何、封面和返回手势由共用宿主处理。 */
+@Composable
+fun ReadAloudPlayerMorphHost(
+    playerViewModel: ReadAloudPlayerViewModel,
+    playerState: ReadAloudPlayerUiState,
+    morph: ReadAloudMorphState,
+    visible: Boolean,
+    awaitCapsuleAnchor: Boolean = false,
+    predictiveBackEnabled: Boolean = true,
+    onDismiss: () -> Unit,
+    onSwitchToClassic: (bookUrl: String) -> Unit,
+) {
+    val settingsState by playerViewModel.readAloudSettings.collectAsStateWithLifecycle()
+    var configVisible by rememberSaveable { mutableStateOf(false) }
+    val expanded by remember { derivedStateOf { morph.expanded } }
+    val currentDismiss by rememberUpdatedState(onDismiss)
+    val currentSwitch by rememberUpdatedState(onSwitchToClassic)
+    LaunchedEffect(expanded) { if (!expanded) configVisible = false }
+    LaunchedEffect(playerViewModel) {
+        playerViewModel.effects.collect { effect ->
+            when (effect) {
+                ReadAloudPlayerEffect.ReturnToClassic -> {
+                    configVisible = false
+                    morph.animateTo(0f)
+                    currentDismiss()
+                    currentSwitch(playerViewModel.uiState.value.bookUrl)
+                }
+            }
+        }
+    }
+    PlayerMorphHost(
+        appearance = PlayerMorphAppearance(
+            playerState.bookName, playerState.author, playerState.coverPath,
+            playerState.sourceOrigin, playerState.bgMode,
+        ),
+        playerTheme = rememberPlayerThemeOverride(playerState),
+        morph = morph,
+        visible = visible,
+        awaitCapsuleAnchor = awaitCapsuleAnchor,
+        predictiveBackEnabled = predictiveBackEnabled,
+        backEnabled = !configVisible && playerState.activeSheet == null,
+        onDismiss = onDismiss,
+    ) { onCollapse ->
+        ReadAloudPlayerScreenContent(
+            state = playerState,
+            onIntent = playerViewModel::onIntent,
+            onBack = onCollapse,
+            onOpenConfig = { configVisible = true },
+        )
+    }
+    AppModalBottomSheet(
+        show = expanded && configVisible,
+        onDismissRequest = { configVisible = false },
+        title = stringResource(R.string.aloud_config),
+    ) {
+        ReadAloudConfigContent(
+            state = settingsState.asReadBookUiState(),
+            playerState = playerState,
+            onIntent = playerViewModel::applyReadBookConfigIntent,
+            onPlayerIntent = playerViewModel::onIntent,
+        )
+    }
+}

+ 23 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudPlayerOverlayBus.kt

@@ -0,0 +1,23 @@
+package io.legado.app.ui.book.readaloud
+
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+/**
+ * 「打开听书播放弹层」的全局请求通道。
+ *
+ * 播放页是 Activity 级 morph 浮层(见 [ReadAloudPlayerMorphHost]),不在导航栈上,
+ * 阅读器的经典控制面板只能通过这条通道请求宿主把它拉起来。
+ * 不设 replay:宿主始终挂在 Activity 上,请求发出时一定有收集方。
+ */
+object ReadAloudPlayerOverlayBus {
+
+    private val _events = MutableSharedFlow<PlaybackCapsuleState>(extraBufferCapacity = 1)
+    val events: SharedFlow<PlaybackCapsuleState> = _events
+
+    fun request(state: PlaybackCapsuleState = PlaybackCapsuleState(source = PlaybackCapsuleSource.ReadAloud)) {
+        _events.tryEmit(state)
+    }
+}

+ 42 - 51
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudShellHost.kt

@@ -4,78 +4,69 @@ import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.semantics.clearAndSetSemantics
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
-import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleState
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
 import io.legado.app.ui.book.read.ReadAloudCapsule
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerUiState
 import org.koin.compose.koinInject
 
-/**
- * 朗读悬浮胶囊的全局叠层。
- *
- * 胶囊在阅读界面之外也要可见,因此不能再挂在 `ReadBookRouteScreen` 里:阅读器只是
- * Navigation 3 的一个目的地,离开它就整棵子树被销毁。这里由宿主 Activity 叠在导航之上,
- * 只要朗读会话在跑就一直在。
- *
- * 数据来自全局的 [ReadAloudSessionStore] 与朗读设置,不依赖任何阅读器 ViewModel,
- * 所以朗读无论从书架、听书页还是通知栏启动,胶囊表现一致。
- *
- * 显隐判据用 [ReadAloudPlayerUiState.readAloudRunning](背后是 `BaseReadAloudService.isRun`,
- * 服务创建/销毁时同步翻转)而不是 [ReadAloudSessionStore] 的 status:后者只在 play/pause/stop
- * 事件里更新,服务仍在跑时可能停在旧值,表现为「回到阅读界面胶囊要等下一段才出现」。
- */
+/** 应用内播放胶囊宿主,共用朗读和有声书状态;播放器初始化仍由各自页面负责。 */
 @Composable
 fun ReadAloudShellHost(
-    playerState: ReadAloudPlayerUiState,
     showCapsule: Boolean,
-    sessionStore: ReadAloudSessionStore = koinInject(),
+    playbackGateway: PlaybackCapsuleGateway = koinInject(),
     settingsGateway: ReadAloudSettingsGateway = koinInject(),
-    bottomPadding: Dp = 16.dp,
     hidden: Boolean = false,
-    onIntent: (ReadAloudPlayerIntent) -> Unit,
+    anchorPreview: PlaybackCapsuleState? = null,
     onCapsulePositionChanged: (x: Float, y: Float) -> Unit,
-    onOpenPlayer: () -> Unit,
+    onOpenPlayer: (PlaybackCapsuleState) -> Unit,
 ) {
-    val session by sessionStore.state.collectAsStateWithLifecycle()
+    val playerState by playbackGateway.state.collectAsStateWithLifecycle()
+    val morph = LocalReadAloudMorph.current
     val settings by settingsGateway.settings.collectAsStateWithLifecycle(
         settingsGateway.currentSettings
     )
 
-    Box(modifier = Modifier.fillMaxSize()) {
-        AnimatedVisibility(
-            visible = !hidden && showCapsule && playerState.readAloudRunning,
-            enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),
-            exit = fadeOut(tween(140)) + scaleOut(tween(180), targetScale = 0.88f),
-        ) {
-            ReadAloudCapsule(
-                bookName = playerState.bookName,
-                author = playerState.author,
-                coverPath = playerState.coverPath,
-                sourceOrigin = playerState.sourceOrigin,
-                isPaused = session.status != ReadAloudSessionStatus.Playing,
-                offsetXDp = settings.capsuleOffsetX,
-                offsetYDp = settings.capsuleOffsetY,
-                progress = session.playback.chapterPosition.toFloat() /
-                        session.playback.chapterLength.coerceAtLeast(1),
-                autoCollapse = settings.capsuleAutoCollapse,
-                bottomPadding = bottomPadding,
-                onPositionChanged = onCapsulePositionChanged,
-                onTogglePause = { onIntent(ReadAloudPlayerIntent.TogglePause) },
-                onStop = { onIntent(ReadAloudPlayerIntent.StopReadAloud) },
-                onOpenPlayer = onOpenPlayer,
-            )
+    // 初次朗读还没有服务会话时,按保存位置布局同一个胶囊,仅测量、不绘制。
+    val previewOnly = playerState.source == null
+    val displayState = if (previewOnly) anchorPreview ?: playerState else playerState
+    val visible = !hidden && showCapsule && (playerState.source != null || anchorPreview != null)
+    CompositionLocalProvider(LocalReadAloudMorph provides morph?.takeIf { visible }) {
+        Box(modifier = Modifier.fillMaxSize()) {
+            AnimatedVisibility(
+                visible = visible,
+                modifier = if (previewOnly) Modifier
+                    .graphicsLayer { alpha = 0f }
+                    .clearAndSetSemantics { } else Modifier,
+                enter = fadeIn(tween(180)),
+                exit = fadeOut(tween(140)),
+            ) {
+                ReadAloudCapsule(
+                    bookName = displayState.bookName,
+                    author = displayState.author,
+                    coverPath = displayState.coverPath,
+                    sourceOrigin = displayState.sourceOrigin,
+                    isPaused = displayState.isPaused,
+                    offsetXDp = settings.capsuleOffsetX,
+                    offsetYDp = settings.capsuleOffsetY,
+                    progress = displayState.progress,
+                    autoCollapse = settings.capsuleAutoCollapse,
+                    onPositionChanged = onCapsulePositionChanged,
+                    onTogglePause = { playerState.source?.let(playbackGateway::togglePause) },
+                    onStop = { playerState.source?.let(playbackGateway::stop) },
+                    onOpenPlayer = { onOpenPlayer(playerState) },
+                )
+            }
         }
     }
 }

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

@@ -0,0 +1,254 @@
+package io.legado.app.ui.book.readaloud.morph
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.geometry.Rect
+
+/**
+ * 「胶囊 ↔ 听书播放页」形变的纯几何计算。
+ *
+ * 形变使用单一 0..1 进度。面板始终按全屏布局,
+ * 本实现按最终坐标裁剪面板,不缩放圆角,也不随进度重测面板布局。
+ *
+ * **面板与封面使用不同的几何曲线**:
+ * - 视觉面(板子):单一线性 `t`;
+ * - 封面:位置分量 `e1 = 1-(1-t)²`(先快)+ 尺寸分量 `e2 = t²`(先慢)。
+ * 共用一条曲线会变成「一个框在等比放大」,板子和封面没有层次。
+ *
+ * 只有「面」和「封面」吃几何插值,文字控件只吃透明度(见 [computeMorphVeil]),
+ * 否则文字会被非等比缩放拉糊。
+ */
+
+/** 遮罩 / 内容开始出现的进度点:过渡前段完全看不出背景在变。 */
+const val MORPH_VEIL_START = 0.85f
+
+/**
+ * 封面旋转归零的进度点。
+ *
+ * 必须早于 [MORPH_VEIL_START]:最后一段内容开始淡入,
+ * 如果那时旋转还没停,交接处会看到一次「转着落地」的抖动。
+ */
+const val MORPH_ROTATION_SETTLE = 0.7f
+
+/**
+ * 收起时保留播放页背景,只有进度降到最后 15% 才交还胶囊底色。
+ * progress 是展开进度:0 为胶囊,1 为播放页,不能把收起末段误写为 0.85..1。
+ */
+const val MORPH_FACE_BLEND_START = 0f
+const val MORPH_FACE_BLEND_END = 0.15f
+
+/** 视觉面形变帧。 */
+@Immutable
+data class PlayerPanelMorphFrame(
+    val bounds: Rect,
+    val cornerRadiusPx: Float,
+    val shadowAlpha: Float,
+    val cornerRadii: PlayerPanelCornerRadii = PlayerPanelCornerRadii.all(cornerRadiusPx),
+)
+
+@Immutable
+data class PlayerPanelCornerRadii(
+    val topLeft: Float,
+    val topRight: Float,
+    val bottomRight: Float,
+    val bottomLeft: Float,
+) {
+    companion object {
+        val Zero = all(0f)
+        fun all(radius: Float) = PlayerPanelCornerRadii(radius, radius, radius, radius)
+    }
+}
+
+/** 封面形变帧。 */
+@Immutable
+data class PlayerCoverMorphFrame(
+    val bounds: Rect,
+    val cornerRadiusPx: Float,
+    val imageScale: Float,
+    val imageRotationDeg: Float,
+    val shadowAlpha: Float,
+)
+
+private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
+
+/** 两端导数为零的缓动;进度类曲线用它接缝更顺。 */
+private fun smoothstep(x: Float): Float {
+    val c = x.coerceIn(0f, 1f)
+    return c * c * (3f - 2f * c)
+}
+
+/** 位置曲线:先快后慢。 */
+internal fun morphPositionEase(t: Float): Float = 1f - (1f - t) * (1f - t)
+
+/** 尺寸曲线:先慢后快。 */
+internal fun morphSizeEase(t: Float): Float = t * t
+
+/**
+ * 视觉面形变:从 [start](胶囊矩形)长到 [end](全屏矩形)。
+ *
+ * **几何用单一线性 `t`**,与封面的两段式曲线刻意分开。
+ * 两条曲线共用一条的后果是「一个框在等比放大」,板子和封面之间没有层次:
+ * 板子匀速长大、封面先慢后快,才有「封面从里面长出来」的观感。
+ *
+ * 起点或终点未测量时返回 null,调用方退化为不做几何插值。
+ */
+fun computePanelMorphFrame(
+    progress: Float,
+    start: Rect,
+    end: Rect,
+    startCornerRadiusPx: Float,
+    endCornerRadiusPx: Float,
+    endCornerRadii: PlayerPanelCornerRadii = PlayerPanelCornerRadii.all(endCornerRadiusPx),
+): PlayerPanelMorphFrame? {
+    if (start.isEmpty || end.isEmpty) return null
+    val t = progress.coerceIn(0f, 1f)
+
+    val w = lerp(start.width, end.width, t)
+    val h = lerp(start.height, end.height, t)
+    val cornerRadiusPx = lerp(
+        startCornerRadiusPx,
+        endCornerRadiusPx.coerceAtMost(minOf(w, h) * 0.5f),
+        t,
+    )
+    return PlayerPanelMorphFrame(
+        bounds = Rect(
+            left = lerp(start.left, end.left, t),
+            top = lerp(start.top, end.top, t),
+            right = lerp(start.right, end.right, t),
+            bottom = lerp(start.bottom, end.bottom, t),
+        ),
+        cornerRadiusPx = cornerRadiusPx,
+        // 阴影比尺寸收敛得快一点(收敛在 e2≈0.85)。
+        shadowAlpha = lerp(1f, 0f, morphSizeEase(t)),
+        cornerRadii = PlayerPanelCornerRadii(
+            topLeft = lerp(startCornerRadiusPx, endCornerRadii.topLeft, t).coerceIn(
+                0f,
+                minOf(w, h) / 2f
+            ),
+            topRight = lerp(startCornerRadiusPx, endCornerRadii.topRight, t).coerceIn(
+                0f,
+                minOf(w, h) / 2f
+            ),
+            bottomRight = lerp(startCornerRadiusPx, endCornerRadii.bottomRight, t).coerceIn(
+                0f,
+                minOf(w, h) / 2f
+            ),
+            bottomLeft = lerp(startCornerRadiusPx, endCornerRadii.bottomLeft, t).coerceIn(
+                0f,
+                minOf(w, h) / 2f
+            ),
+        ),
+    )
+}
+
+/**
+ * 把累加旋转角折到 `(-180, 180]`。
+ *
+ * 胶囊封面是「每 12 秒转一圈」的累加动画(`Animatable` 一直在 `+360`),
+ * 播放十几分钟后这个值可以到几千度。直接拿它做 `× (1 - e2)` 的插值,
+ * 收起时飞行封面会顺着插值把前面攒下的每一圈都转一遍——这正是「返回时封面旋转很多次」的来源。
+ *
+ * 视觉上旋转角取模 360 完全等价,折到 `(-180, 180]` 还顺带保证走最短路径:
+ * 形变期间最多转过半圈。
+ */
+fun normalizeMorphRotationDeg(deg: Float): Float {
+    if (!deg.isFinite()) return 0f
+    val wrapped = deg % 360f
+    return when {
+        wrapped > 180f -> wrapped - 360f
+        wrapped <= -180f -> wrapped + 360f
+        else -> wrapped
+    }
+}
+
+/**
+ * 封面形变:从胶囊里的圆形小封面长到播放页封面页的大封面。
+ *
+ * [imageRotationDeg] 是胶囊封面当前的旋转角(累加值),先归一化到半圈以内,
+ * 再在 [MORPH_ROTATION_SETTLE] 之前回落到 0 —— 必须早于内容淡入,交接才不会「转着落地」。
+ */
+fun computeCoverMorphFrame(
+    progress: Float,
+    start: Rect,
+    end: Rect,
+    startCornerRadiusPx: Float,
+    endCornerRadiusPx: Float,
+    imageRotationDeg: Float = 0f,
+): PlayerCoverMorphFrame? {
+    if (start.isEmpty || end.isEmpty) return null
+    val t = progress.coerceIn(0f, 1f)
+    val e1 = morphPositionEase(t)
+    val e2 = morphSizeEase(t)
+
+    // 中心的 X 与 Y 用**不同**的缓动:X 先快(贴着胶囊横向就位),Y 跟尺寸一样先慢。
+    // 这是「从胶囊里长出来」而不是「从胶囊里滑出来」的关键。
+    val cx = lerp(start.center.x, end.center.x, e1)
+    val cy = lerp(start.center.y, end.center.y, e2)
+    val w = lerp(start.width, end.width, e2)
+    val h = lerp(start.height, end.height, e2)
+    val maxRadius = minOf(w, h) * 0.5f
+    return PlayerCoverMorphFrame(
+        bounds = Rect(cx - w / 2f, cy - h / 2f, cx + w / 2f, cy + h / 2f),
+        cornerRadiusPx = lerp(startCornerRadiusPx, endCornerRadiusPx, e2)
+            .coerceAtMost(maxRadius),
+        // 页面封面不缩放,终点必须与它逐像素一致。
+        imageScale = 1f,
+        imageRotationDeg = normalizeMorphRotationDeg(imageRotationDeg) *
+                (1f - smoothstep(t / MORPH_ROTATION_SETTLE)),
+        shadowAlpha = lerp(1f, 0f, e2),
+    )
+}
+
+/**
+ * 内容可见度:前 [MORPH_VEIL_START] 段保持全透明,最后一段才淡入。
+ *
+ * 单独一条曲线是刻意的:内容一旦参与几何缩放就会被拉糊,所以它只吃透明度。
+ * 收起时反过来用(进度越小越透明)。
+ *
+ * 内容可见度使用线性窗口。
+ */
+fun computeMorphVeil(
+    progress: Float,
+    veilStart: Float = MORPH_VEIL_START,
+): Float {
+    val t = progress.coerceIn(0f, 1f)
+    if (veilStart >= 1f) return t
+    return ((t - veilStart) / (1f - veilStart)).coerceIn(0f, 1f)
+}
+
+/**
+ * 视觉面的交接进度:0 = 完全是起点胶囊的脸,1 = 完全是播放页背景。
+ *
+ * 面板的起点就是胶囊本身(同矩形、同圆角),所以进度 0 时面板必须长得和胶囊一模一样,
+ * 否则一按下去就会「先跳到播放页配色、再从那里缩小回胶囊」——收起时最明显。
+ * 单条曲线同时驱动底色与背景透明度,两者不会脱节。
+ */
+fun computeMorphFaceBlend(
+    progress: Float,
+    blendStart: Float = MORPH_FACE_BLEND_START,
+    blendEnd: Float = MORPH_FACE_BLEND_END,
+): Float {
+    val t = progress.coerceIn(0f, 1f)
+    if (blendEnd <= blendStart) return if (t >= blendEnd) 1f else 0f
+    return ((t - blendStart) / (blendEnd - blendStart)).coerceIn(0f, 1f)
+}
+
+/** 接近胶囊时交还原始材质,避免进度归零时才把纯色面切换成玻璃。 */
+fun computeMorphPanelAlpha(progress: Float): Float = smoothstep(progress / 0.15f)
+
+/** 无封面页面返回时,封面留在胶囊端点,随末段材质交接平滑渐显。 */
+fun computeMorphFlyingCoverAlpha(progress: Float, coverPageVisible: Boolean): Float {
+    if (progress <= 0f || progress >= 1f) return 0f
+    return if (coverPageVisible) 1f else 1f - computeMorphPanelAlpha(progress)
+}
+
+/** 临界阻尼弹簧临近终点的速度上限:避免甩动越过端点后切换封面。 */
+fun computeMorphSettleVelocity(current: Float, target: Float, velocity: Float): Float {
+    val distance = kotlin.math.abs(target - current)
+    val limit = minOf(3f, kotlin.math.sqrt(260f) * distance)
+    return velocity.coerceIn(-limit, limit)
+}
+
+
+/** 系统返回只预览部分收起,保留剩余运动交给确认后的弹簧;取消时仍能恢复。 */
+fun computePredictiveMorphProgress(startProgress: Float, backProgress: Float): Float =
+    startProgress.coerceIn(0f, 1f) * (1f - 0.45f * backProgress.coerceIn(0f, 1f))

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

@@ -0,0 +1,301 @@
+package io.legado.app.ui.book.readaloud.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.Immutable
+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.runtime.staticCompositionLocalOf
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+
+enum class CapsuleAnchorKind { HomeBar, Global }
+
+@Immutable
+data class CapsuleProgressRing(val progress: Float, val color: Color, val strokeWidthPx: Float)
+
+/**
+ * 「胶囊 ↔ 听书播放页」形变的唯一进度源与锚点仓库。
+ *
+ * 单一进度同时驱动视觉面、封面、内容透明度与阴影;两端(胶囊 / 播放页封面)
+ * 通过布局回调上报自己的矩形;旋转角独立同步,不依赖重新布局。
+ *
+ * 这是纯动画状态:不进 ViewModel、不进 UiState、不持久化。
+ */
+@Stable
+class ReadAloudMorphState(
+    val progress: Animatable<Float, AnimationVector1D>,
+    private val density: Density,
+) {
+
+    /** 播放面展开后的目标矩形(宿主容器,即整窗)。 */
+    var screenBounds by mutableStateOf(Rect.Zero)
+        private set
+
+    var screenCornerRadii by mutableStateOf(PlayerPanelCornerRadii.Zero)
+        private set
+
+    fun reportScreenCorners(radii: PlayerPanelCornerRadii) {
+        screenCornerRadii = radii
+    }
+
+    var capsuleProgressRing by mutableStateOf<CapsuleProgressRing?>(null)
+        private set
+
+    fun reportProgressRing(ring: CapsuleProgressRing?) {
+        capsuleProgressRing = ring
+    }
+
+    /** 胶囊整体矩形与圆角。 */
+    var panelStartBounds by mutableStateOf(Rect.Zero)
+        private set
+    var panelStartCornerRadiusPx by mutableFloatStateOf(0f)
+        private set
+
+    /**
+     * 起点胶囊的脸色。
+     *
+     * 面板在进度 0 时要与胶囊完全重合,所以底色必须由胶囊上报:阅读界面的可拖拽胶囊是
+     * 黑白纯色,底栏式胶囊是主题容器色,两者不同,宿主猜不出来。
+     */
+    var panelStartColor by mutableStateOf(Color.Unspecified)
+        private set
+
+    /** 胶囊封面矩形与圆角。 */
+    var coverStartBounds by mutableStateOf(Rect.Zero)
+        private set
+    var coverStartCornerRadiusPx by mutableFloatStateOf(0f)
+        private set
+
+    /** 播放页封面页的封面矩形与圆角。 */
+    var coverEndBounds by mutableStateOf(Rect.Zero)
+        private set
+    var coverEndCornerRadiusPx by mutableFloatStateOf(0f)
+        private set
+
+    /** 关闭前是否位于封面页;动画中冻结,目录/歌词返回时在胶囊位置渐显封面。 */
+    var coverPageVisible by mutableStateOf(true)
+        private set
+    private var latestCoverEndBounds = Rect.Zero
+    private var latestCoverEndRadiusPx = 0f
+
+    fun reportCoverPageVisible(visible: Boolean) {
+        if (progress.value > 0f && !expanded) return
+        coverPageVisible = visible
+        // Pager 的最终布局可能先于可见性事件到达;回到封面页时补上最新布局。
+        if (visible && !latestCoverEndBounds.isEmpty) {
+            coverEndBounds = latestCoverEndBounds
+            coverEndCornerRadiusPx = latestCoverEndRadiusPx
+        }
+    }
+
+    /** 胶囊封面的旋转角,形变期间回落到 0。 */
+    var coverRotationDeg by mutableFloatStateOf(0f)
+        private set
+
+    /**
+     * 用户是否正在拖动进度(胶囊 ↔ 播放页)。
+     *
+     * 内容层在未展开时会吞掉触摸事件(避免点到看不见的播放页);但拖动期间必须放行,
+     * 否则闸门会把宿主自身的拖拽也吞掉,手势直接失效。
+     */
+    var dragging by mutableStateOf(false)
+
+    /** 播放面是否已完全展开(也是「内容可交互」的判据)。 */
+    val expanded: Boolean get() = progress.value >= 1f
+
+    private var fadeOnlyOpening by mutableStateOf(false)
+
+    fun prepareOpeningAnchors() {
+        fadeOnlyOpening = false
+    }
+
+    /** 锚点等待失败后固定本次展开为淡入,迟到的测量不能在中途改变路径。 */
+    fun useFadeOnlyOpening() {
+        clearStartAnchors()
+        fadeOnlyOpening = true
+    }
+
+    /** 路由/朗读会话改变后不沿用上一种胶囊的起点。 */
+    fun clearStartAnchors() {
+        if (progress.value > 0f && !expanded) return
+        panelStartBounds = Rect.Zero
+        coverStartBounds = Rect.Zero
+        panelStartCornerRadiusPx = 0f
+        coverStartCornerRadiusPx = 0f
+        panelStartColor = Color.Unspecified
+        capsuleProgressRing = null
+    }
+
+    private var expectedAnchorKind by mutableStateOf<CapsuleAnchorKind?>(null)
+    private var panelAnchorKind by mutableStateOf<CapsuleAnchorKind?>(null)
+    private var coverAnchorKind by mutableStateOf<CapsuleAnchorKind?>(null)
+    private val panelAnchorMatches get() = expectedAnchorKind == null || panelAnchorKind == expectedAnchorKind
+    private val coverAnchorMatches get() = expectedAnchorKind == null || coverAnchorKind == expectedAnchorKind
+    val hasCapsuleAnchors: Boolean
+        get() = !panelStartBounds.isEmpty && !coverStartBounds.isEmpty &&
+                panelAnchorMatches && coverAnchorMatches
+
+    /** 路由交接不能用上一种胶囊的残留测量开始动画。 */
+    fun expectCapsuleAnchors(kind: CapsuleAnchorKind?) {
+        if (progress.value <= 0f || expanded) expectedAnchorKind = kind
+    }
+
+    /** 不同播放会话可以共用胶囊位置,但不能把封面当作同一张图片飞行。 */
+    var capsuleCoverLinked by mutableStateOf(true)
+        private set
+
+    fun reportCapsuleCoverLinked(linked: Boolean) {
+        if (progress.value <= 0f || expanded) capsuleCoverLinked = linked
+    }
+
+    /** 内容可见度:前 85% 保持全透明。 */
+    val veil: Float get() = computeMorphVeil(progress.value)
+
+    fun reportScreenBounds(bounds: Rect) {
+        if (screenBounds != bounds) screenBounds = bounds
+    }
+
+    /**
+     * 上报胶囊端点。收起和完全展开时可更新,只有过渡期间冻结。
+     *
+     * 过渡开始后冻结,完全展开后重新接收真实位置:胶囊可能在启动播放后才出现,
+     * 或发生折叠改变尺寸、
+     * 用户点击时折叠态在展开、路由切换让阅读界面的可拖拽胶囊退出而底栏式胶囊进场。
+     * 不冻结的话面板起点会中途跳位、面色跟着换,收起时尤其明显。
+     */
+    fun reportPanelStart(
+        bounds: Rect,
+        cornerRadiusPx: Float,
+        surfaceColor: Color = Color.Unspecified,
+        anchorKind: CapsuleAnchorKind? = null,
+    ) {
+        if (fadeOnlyOpening || (progress.value > 0f && !expanded)) return
+        panelAnchorKind = anchorKind
+        if (panelStartBounds != bounds) panelStartBounds = bounds
+        panelStartCornerRadiusPx = cornerRadiusPx
+        if (surfaceColor.isSpecified) panelStartColor = surfaceColor
+    }
+
+    /**
+     * 上报封面起点。矩形同样只在进度 0 时冻结;旋转角**每次都写**。
+     *
+     * 旋转角是例外:胶囊封面在形变全程仍在转,飞行封面必须跟着它,落地那一刻才接得上
+     * (胶囊自己的封面是同一角度)。冻结它反而会在收尾时看到一次跳角。
+     */
+    fun reportCoverStart(
+        bounds: Rect,
+        cornerRadiusPx: Float,
+        rotationDeg: Float = 0f,
+        anchorKind: CapsuleAnchorKind? = null,
+    ) {
+        if (!fadeOnlyOpening && (progress.value <= 0f || expanded)) {
+            coverAnchorKind = anchorKind
+            if (coverStartBounds != bounds) coverStartBounds = bounds
+            coverStartCornerRadiusPx = cornerRadiusPx
+        }
+        reportCoverRotation(rotationDeg)
+    }
+
+    /** 旋转不触发布局回调,独立同步当前角度,保留已冻结的几何起点。 */
+    fun reportCoverRotation(rotationDeg: Float) {
+        coverRotationDeg = rotationDeg
+    }
+
+    fun reportCoverEnd(bounds: Rect, cornerRadiusPx: Float) {
+        // Pager 会把不可见的封面放到屏幕外,不能用它覆盖封面页的真实终点。
+        if (bounds.isEmpty) return
+        latestCoverEndBounds = bounds
+        latestCoverEndRadiusPx = cornerRadiusPx
+        if (!coverPageVisible) return
+        if (coverEndBounds != bounds) coverEndBounds = bounds
+        coverEndCornerRadiusPx = cornerRadiusPx
+    }
+
+    /** 视觉面形变帧;两端未测量时退化为不做几何插值。 */
+    fun panelFrame(): PlayerPanelMorphFrame? = computePanelMorphFrame(
+        progress = progress.value,
+        start = if (panelAnchorMatches) panelStartBounds else Rect.Zero,
+        end = screenBounds,
+        startCornerRadiusPx = panelStartCornerRadiusPx,
+        endCornerRadiusPx = 0f,
+        endCornerRadii = screenCornerRadii,
+    )
+
+    /** 封面形变帧;缺任一端就没有飞行封面。 */
+    fun coverFrame(): PlayerCoverMorphFrame? {
+        if (!capsuleCoverLinked || !coverAnchorMatches || coverStartBounds.isEmpty) return null
+        val start = coverStartBounds
+        if (coverPageVisible && coverEndBounds.isEmpty) return null
+        return computeCoverMorphFrame(
+            progress = if (coverPageVisible) progress.value else 0f,
+            start = start,
+            end = coverEndBounds.takeIf { !it.isEmpty } ?: start,
+            startCornerRadiusPx = coverStartCornerRadiusPx,
+            endCornerRadiusPx = coverEndCornerRadiusPx,
+            imageRotationDeg = coverRotationDeg,
+        )
+    }
+
+    /** 手势拖动一整段收起所需的像素距离,用于把位移换算成进度。 */
+    fun dragRangePx(): Float =
+        (screenBounds.height * 0.45f).coerceAtLeast(with(density) { 120.dp.toPx() })
+
+    /**
+     * 展开/收起动画。收起时会先把进度归零,由调用方在动画结束后真正关闭播放页。
+     *
+     * [initialVelocity] 是拖动手势结束时的进度速度(进度/秒),交给弹簧承接。
+     * 不传就会在手指抬起处「顿一下」再重新起步,这是拖动收起最明显的生硬感来源。
+     */
+    suspend fun animateTo(target: Float, initialVelocity: Float = 0f) {
+        // 兜底:万一拖动手势没走到 end/cancel(例如中途被取消),别让内容层一直放行触摸。
+        dragging = false
+        val clamped = target.coerceIn(0f, 1f)
+        val velocity =
+            if (initialVelocity == 0f && progress.isRunning) progress.velocity else initialVelocity
+        progress.animateTo(
+            targetValue = clamped,
+            // 所有入口共用同一动力学,避免 tween 与弹簧切换时节奏断裂。
+            // 两端高精度收敛,沿用当前速度处理动画中断与手势接管。
+            animationSpec = SETTLE_SPEC,
+            initialVelocity = computeMorphSettleVelocity(progress.value, clamped, velocity),
+        )
+        // 弹簧可能有微小残差,收尾对齐,保证 [expanded] 与内容可见度判定稳定。
+        progress.snapTo(clamped)
+        fadeOnlyOpening = false
+    }
+
+    private companion object {
+        /** 临界阻尼,手势结算两端保持相同手感。 */
+        // 默认 0.01 的残差在封面横向路径上仍可达数像素,不能直接当作到位。
+        val SETTLE_SPEC =
+            spring<Float>(dampingRatio = 1f, stiffness = 260f, visibilityThreshold = 0.0001f)
+
+    }
+}
+
+/** 胶囊/播放页两侧通过它上报锚点、读取形变进度。 */
+val LocalReadAloudMorph = staticCompositionLocalOf<ReadAloudMorphState?> { null }
+
+@Composable
+fun rememberReadAloudMorphState(
+    initialProgress: Float = 0f,
+): ReadAloudMorphState {
+    val density = LocalDensity.current
+    return remember(density) {
+        ReadAloudMorphState(
+            progress = Animatable(initialProgress),
+            density = density,
+        )
+    }
+}

+ 22 - 13
app/src/main/java/io/legado/app/ui/book/readaloud/player/PlayerThemeOverride.kt

@@ -18,38 +18,47 @@ import io.legado.app.model.BookCover as BookCoverModel
  * 与阅读器内播放页同源:按显示封面(含默认封面日夜选择)取种子色,再派生主题覆盖。
  */
 @Composable
-internal fun rememberPlayerThemeOverride(state: ReadAloudPlayerUiState) = run {
+internal fun rememberPlayerThemeOverride(state: ReadAloudPlayerUiState) =
+    rememberPlayerThemeOverride(state.bookName, state.author, state.coverPath, state.sourceOrigin)
+
+@Composable
+internal fun rememberPlayerThemeOverride(
+    bookName: String,
+    author: String,
+    coverPath: String?,
+    sourceOrigin: String?,
+) = run {
     val imageLoader: ImageLoader = koinInject()
     val coverSettings = koinInject<CoverSettingsGateway>().currentSettings
     val isNight = LegadoTheme.isDark
-    val useDefaultCover = usesDefaultBookCover(state.coverPath)
+    val useDefaultCover = usesDefaultBookCover(coverPath)
     val defaultCoverPaths =
         if (isNight) coverSettings.defaultCoverDark else coverSettings.defaultCover
-    val coverPath = remember(
-        state.bookName,
-        state.author,
-        state.coverPath,
+    val resolvedCoverPath = remember(
+        bookName,
+        author,
+        coverPath,
         useDefaultCover,
         isNight,
         defaultCoverPaths,
     ) {
         if (useDefaultCover) {
-            BookCoverModel.getRandomDefaultPath(seed = state.bookName, isNight = isNight)
+            BookCoverModel.getRandomDefaultPath(seed = bookName, isNight = isNight)
         } else {
-            state.coverPath
+            coverPath
         }
     }
-    val sourceOrigin = if (useDefaultCover) null else state.sourceOrigin
+    val resolvedSourceOrigin = if (useDefaultCover) null else sourceOrigin
     val loadOnlyWifi = !useDefaultCover && coverSettings.loadOnlyOnWifi
-    val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
-        listOf(coverPath, sourceOrigin, loadOnlyWifi)
+    val requestKey = remember(resolvedCoverPath, resolvedSourceOrigin, loadOnlyWifi) {
+        listOf(resolvedCoverPath, resolvedSourceOrigin, loadOnlyWifi)
     }
     val seedColor = rememberImageSeedColor(
         imageLoader = imageLoader,
-        data = coverPath,
+        data = resolvedCoverPath,
         requestKey = requestKey,
     ) {
-        extras[CoverExtras.SourceOrigin] = sourceOrigin
+        extras[CoverExtras.SourceOrigin] = resolvedSourceOrigin
         extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
     }
     rememberThemeOverride(seedColor)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudConfigIntentMapper.kt

@@ -6,7 +6,7 @@ import io.legado.app.ui.book.read.ReadBookIntent
  * 把配置卡片的 [ReadBookIntent] 翻译成全局设置写入。
  *
  * 配置内容(`ReadAloudConfigContent`)的契约是 `ReadBookIntent`,因为它的主宿主是阅读器。
- * 听书播放界面是独立目的地、没有阅读器 ViewModel,所以这里把同一批意图落到
+ * 听书播放弹层是全局浮层、没有阅读器 ViewModel,所以这里把同一批意图落到
  * [ReadAloudPlayerViewModel.onConfigIntent],两个宿主的设置语义完全一致。
  */
 internal fun ReadAloudPlayerViewModel.applyReadBookConfigIntent(intent: ReadBookIntent) {

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

@@ -66,7 +66,6 @@ sealed interface ReadAloudPlayerIntent {
     data object NextChapter : ReadAloudPlayerIntent
     data object PreviousParagraph : ReadAloudPlayerIntent
     data object NextParagraph : ReadAloudPlayerIntent
-    data object OpenSettings : ReadAloudPlayerIntent
     data object SwitchToClassic : ReadAloudPlayerIntent
     data object CycleBgMode : ReadAloudPlayerIntent
     data class SelectChapter(val index: Int) : ReadAloudPlayerIntent
@@ -88,6 +87,5 @@ sealed interface ReadAloudPlayerIntent {
 }
 
 sealed interface ReadAloudPlayerEffect {
-    data object ReturnToReaderSettings : ReadAloudPlayerEffect
     data object ReturnToClassic : ReadAloudPlayerEffect
 }

+ 72 - 18
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt

@@ -15,6 +15,7 @@ import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
+import io.legado.app.model.reader.ReaderChapterInput
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import kotlinx.collections.immutable.ImmutableList
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
@@ -49,8 +51,15 @@ class ReadAloudPlayerCoordinator(
         }
     }
     private val configChanges = ReadConfigUpdateBus.events.map { }
-    private val bookState =
-        merge(bookChanges, refreshRequests, configChanges).map { snapshotBook() }
+
+    /**
+     * `TTS_PROGRESS` 会按词回调(见 `BaseReadAloudService.upTtsProgress`),但绝大多数事件并不会
+     * 改变快照内容。这里去重,避免每次进度事件都让下游 `flatMapLatest` 重新订阅 Room 章节流、
+     * 重建目录列表实例(对照 `AudioPlayCoordinator.chapters` 的同名处理)。
+     */
+    private val bookState = merge(bookChanges, refreshRequests, configChanges)
+        .map { snapshotBook() }
+        .distinctUntilChanged()
 
     @OptIn(ExperimentalCoroutinesApi::class)
     private val bookWithChapters = bookState.flatMapLatest { book ->
@@ -136,24 +145,13 @@ class ReadAloudPlayerCoordinator(
         refreshRequests.tryEmit(Unit)
     }
 
+    private var chapterSnapshotKey: ChapterSnapshotKey? = null
+    private var chapterSnapshotCache: ChapterSnapshot? = null
+
     private fun snapshotBook(): BookState {
         val book = ReadBook.book
         val input = ReadBook.readerChapterInputWindow.current
-        val settings = readAloudSettingsGateway.currentSettings
-        val chapter = input?.let {
-            ReaderReadAloudChapter.create(
-                chapterIndex = it.chapter.index,
-                title = it.displayTitle,
-                semanticContent = it.source.semanticContent,
-                pageStarts = ReadBook.readerPagination(it.chapter.index)?.pageStarts.orEmpty(),
-                // 与朗读服务同口径:「默认」在多角色关闭时落到整段,否则听书页展示的
-                // 文本行会与服务实际播放的单元粒度不一致。
-                contentSplitMode = ContentSplitPolicies.resolve(
-                    mode = ReadAloudContentSplitMode.fromStorage(settings.contentSplitMode),
-                    useMultiSpeaker = settings.useMultiSpeaker,
-                ),
-            )
-        }
+        val chapter = input?.let(::chapterSnapshot)
         return BookState(
             bookUrl = book?.bookUrl.orEmpty(),
             bookName = book?.name.orEmpty(),
@@ -163,13 +161,53 @@ class ReadAloudPlayerCoordinator(
             chapterIndex = chapter?.chapterIndex ?: -1,
             chapterTitle = chapter?.title.orEmpty(),
             chapterText = input?.source?.semanticContent.orEmpty(),
-            textLines = chapter?.paragraphs.orEmpty().mapNotNull { paragraph ->
+            textLines = chapter?.textLines ?: persistentListOf(),
+        )
+    }
+
+    /**
+     * 章节切分与文本行派生。
+     *
+     * 整章切分([ReaderReadAloudChapter.create])和随后的逐段字符清洗都跑在主线程上,而
+     * `snapshotBook()` 会被每个 TTS 进度事件(逐词回调)触发,所以这里按真正影响结果的输入
+     * 记忆化:只有章节内容、标题、分页或划分方式变化时才重建。
+     */
+    private fun chapterSnapshot(input: ReaderChapterInput): ChapterSnapshot {
+        val settings = readAloudSettingsGateway.currentSettings
+        val key = ChapterSnapshotKey(
+            chapterIndex = input.chapter.index,
+            title = input.displayTitle,
+            semanticContent = input.source.semanticContent,
+            pageStarts = ReadBook.readerPagination(input.chapter.index)?.pageStarts.orEmpty(),
+            // 与朗读服务同口径:「默认」在多角色关闭时落到整段,否则听书页展示的
+            // 文本行会与服务实际播放的单元粒度不一致。
+            contentSplitMode = ContentSplitPolicies.resolve(
+                mode = ReadAloudContentSplitMode.fromStorage(settings.contentSplitMode),
+                useMultiSpeaker = settings.useMultiSpeaker,
+            ),
+        )
+        val cached = chapterSnapshotCache
+        if (cached != null && chapterSnapshotKey == key) return cached
+        val chapter = ReaderReadAloudChapter.create(
+            chapterIndex = key.chapterIndex,
+            title = key.title,
+            semanticContent = key.semanticContent,
+            pageStarts = key.pageStarts,
+            contentSplitMode = key.contentSplitMode,
+        )
+        val snapshot = ChapterSnapshot(
+            chapterIndex = chapter.chapterIndex,
+            title = chapter.title,
+            textLines = chapter.paragraphs.mapNotNull { paragraph ->
                 paragraph.text.replace(Regex("[袮祢꧁\uFFFC]"), " ").trim()
                     .takeIf(String::isNotEmpty)?.let {
                     ReadAloudTextLineUi(it, paragraph.chapterPosition)
                 }
             }.toImmutableList(),
         )
+        chapterSnapshotKey = key
+        chapterSnapshotCache = snapshot
+        return snapshot
     }
 
     fun togglePause() {
@@ -270,6 +308,22 @@ class ReadAloudPlayerCoordinator(
         val chapterText: String,
         val textLines: ImmutableList<ReadAloudTextLineUi>,
     )
+
+    /** 影响章节切分结果的输入;任一项变化都需要重建 [ChapterSnapshot]。 */
+    private data class ChapterSnapshotKey(
+        val chapterIndex: Int,
+        val title: String,
+        val semanticContent: String,
+        val pageStarts: List<Int>,
+        val contentSplitMode: ReadAloudContentSplitMode,
+    )
+
+    /** 听书页展示需要的章节派生结果。 */
+    private data class ChapterSnapshot(
+        val chapterIndex: Int,
+        val title: String,
+        val textLines: ImmutableList<ReadAloudTextLineUi>,
+    )
 }
 
 data class ReadAloudChapterSourceState(

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

@@ -1,75 +0,0 @@
-package io.legado.app.ui.book.readaloud.player
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.res.stringResource
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import io.legado.app.R
-import io.legado.app.ui.book.read.sheet.ReadAloudConfigContent
-import io.legado.app.ui.book.read.sheet.asReadBookUiState
-import io.legado.app.ui.theme.ProvideThemeOverride
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import org.koin.compose.koinInject
-
-/**
- * 听书播放界面(Navigation 3 目的地)。
- *
- * 它是一层普通全屏目的地:整页纵向进出动画完全由 nav3 的
- * `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` 提供
- * (见 `MainNavGraph.readAloudPlayerEntryMetadata`)。因此这里不需要 `ModalBottomSheet`
- * 这类窗口级 sheet 容器,也不自己驱动位移,没有「弹层里再开弹层」的
- * shape / 宽度 / 返回键特判;代价是没有下拉关闭手势。
- *
- * 设置卡片用的是全局 [ReadAloudPlayerViewModel] 的设置快照,不依赖阅读器 ViewModel,
- * 所以从胶囊直接进听书页时同样能改朗读设置。
- */
-@Composable
-fun ReadAloudPlayerRouteScreen(
-    showReadAloudConfig: Boolean,
-    onReadAloudConfigVisibleChange: (Boolean) -> Unit,
-    onBack: () -> Unit,
-    /**
-     * 「经典控制」按钮:交给宿主决定回到已有阅读界面还是新开阅读界面。
-     * 参数是当前朗读的书籍 url。
-     */
-    onSwitchToClassic: (bookUrl: String) -> Unit,
-) {
-    val playerViewModel: ReadAloudPlayerViewModel = koinInject()
-    val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
-    val settingsState by playerViewModel.readAloudSettings.collectAsStateWithLifecycle()
-    val playerTheme = rememberPlayerThemeOverride(playerState)
-
-    LaunchedEffect(playerViewModel) {
-        playerViewModel.effects.collect { effect ->
-            when (effect) {
-                ReadAloudPlayerEffect.ReturnToClassic ->
-                    onSwitchToClassic(playerViewModel.uiState.value.bookUrl)
-
-                ReadAloudPlayerEffect.ReturnToReaderSettings -> Unit
-            }
-        }
-    }
-
-    ProvideThemeOverride(playerTheme) {
-        ReadAloudPlayerScreenContent(
-            state = playerState,
-            onIntent = playerViewModel::onIntent,
-            onBack = onBack,
-            onOpenConfig = { onReadAloudConfigVisibleChange(true) },
-        )
-    }
-    // 播放页自己是一层全屏目的地,这里只叠一层设置卡片,全屏只有这一层 scrim。
-    AppModalBottomSheet(
-        show = showReadAloudConfig,
-        onDismissRequest = { onReadAloudConfigVisibleChange(false) },
-        title = stringResource(R.string.aloud_config),
-    ) {
-        ReadAloudConfigContent(
-            state = settingsState.asReadBookUiState(),
-            playerState = playerState,
-            onIntent = playerViewModel::applyReadBookConfigIntent,
-            onPlayerIntent = playerViewModel::onIntent,
-        )
-    }
-}

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

@@ -88,7 +88,9 @@ import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallAnimatedButton
 import io.legado.app.ui.widget.components.card.TextCard
-import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.core.ui.player.PlayerMorphCover
+import io.legado.app.core.ui.player.PlayerMorphAppearance
+import io.legado.app.core.ui.player.TrackPlayerMorphCoverPage
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
@@ -102,7 +104,7 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import top.yukonga.miuix.kmp.blur.BlendColorEntry
 import top.yukonga.miuix.kmp.blur.BlurBlendMode
-import top.yukonga.miuix.kmp.blur.BlurColors
+import top.yukonga.miuix.kmp.blur.BlurDefaults
 import top.yukonga.miuix.kmp.blur.layerBackdrop
 import top.yukonga.miuix.kmp.blur.textureBlur
 import kotlin.math.abs
@@ -119,6 +121,7 @@ fun ReadAloudPlayerScreenContent(
     onOpenConfig: () -> Unit,
 ) {
     val horizontalPagerState = rememberPagerState(initialPage = 1, pageCount = { 3 })
+    TrackPlayerMorphCoverPage(horizontalPagerState)
     var isTextPageUserScrolling by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
@@ -126,13 +129,19 @@ fun ReadAloudPlayerScreenContent(
     val textBackdrop = rememberBlurBackdrop()
     val flowingLightActive = state.bgMode == ReadAloudBgMode.FlowingLight
     val flowingTextModifier = if (flowingLightActive && textBackdrop != null) {
-        Modifier.textureBlur(
-            backdrop = textBackdrop,
-            shape = RoundedCornerShape(4.dp),
-            blurRadius = 150f,
-            colors = BlurColors(blendColors = flowingTextBlend()),
-            contentBlendMode = ComposeBlendMode.DstIn,
-        )
+        // textureBlur 每次调用都会新建内部 effects lambda,元素 equals 必然失败,
+        // 节点会对每次重组重跑一遍效果管线。这里用 remembered 的颜色与 Modifier,
+        // 让所有引用它的文本共用同一个元素实例,重组时直接命中 equals 短路。
+        val flowingTextColors = BlurDefaults.blurColors(blendColors = flowingTextBlend())
+        remember(textBackdrop, flowingTextColors) {
+            Modifier.textureBlur(
+                backdrop = textBackdrop,
+                shape = RoundedCornerShape(4.dp),
+                blurRadius = 150f,
+                colors = flowingTextColors,
+                contentBlendMode = ComposeBlendMode.DstIn,
+            )
+        }
     } else {
         Modifier
     }
@@ -167,6 +176,7 @@ fun ReadAloudPlayerScreenContent(
                     .fillMaxWidth()
                     .clip(RectangleShape)
                     .then(hazeModifier)
+                    // 只挡点击;纵向拖动留给 morph 宿主统一接管(见 ReadAloudPlayerMorphHost)。
                     .pointerInput(Unit) { detectTapGestures(onTap = {}) }
                     .windowInsetsPadding(WindowInsets.statusBars),
             ) {
@@ -432,15 +442,13 @@ private fun CoverPage(
             modifier = Modifier.fillMaxWidth(),
             contentAlignment = Alignment.TopCenter,
         ) {
-            BookCoverImage(
-                name = state.bookName,
-                author = state.author,
-                path = state.coverPath,
-                sourceOrigin = state.sourceOrigin,
+            PlayerMorphCover(
+                appearance = PlayerMorphAppearance(
+                    state.bookName, state.author, state.coverPath, state.sourceOrigin, state.bgMode,
+                ),
                 modifier = Modifier
                     .fillMaxWidth(0.52f)
-                    .aspectRatio(5f / 7f)
-                    .clip(RoundedCornerShape(8.dp))
+                    .aspectRatio(5f / 7f),
             )
         }
         AnimatedContent(

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

@@ -15,6 +15,8 @@ import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsInt
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.widget.components.player.PlayerChapterUi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -77,7 +79,6 @@ class ReadAloudPlayerViewModel(
             ReadAloudPlayerIntent.PreviousChapter -> coordinator.previousChapter()
             ReadAloudPlayerIntent.NextChapter -> coordinator.nextChapter()
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
-            ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.CycleBgMode -> cycleBgMode()
             is ReadAloudPlayerIntent.SelectChapter -> coordinator.selectChapter(intent.index)
             is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
@@ -204,6 +205,32 @@ class ReadAloudPlayerViewModel(
         AppConfigStore.putInt(PreferKey.readAloudPlayerBgMode, next)
     }
 
+    /**
+     * 目录列表映射缓存。
+     *
+     * `toUiState` 会随每个 TTS 进度事件(逐词回调)重跑,但目录只在 Room 章节流发新值时变化;
+     * 长书上每次重映射几千个章节是纯浪费。
+     */
+    private var chaptersCacheSource: ImmutableList<ReadAloudChapterSourceState>? = null
+    private var chaptersCache: ImmutableList<PlayerChapterUi> = persistentListOf()
+
+    private fun chaptersOf(
+        source: ImmutableList<ReadAloudChapterSourceState>,
+    ): ImmutableList<PlayerChapterUi> {
+        chaptersCacheSource?.takeIf { it == source }?.let { return chaptersCache }
+        return source.map { chapter ->
+            PlayerChapterUi(
+                index = chapter.index,
+                title = chapter.title,
+                isVolume = chapter.isVolume,
+                tocLevel = chapter.tocLevel,
+            )
+        }.toImmutableList().also {
+            chaptersCacheSource = source
+            chaptersCache = it
+        }
+    }
+
     private fun toUiState(
         source: ReadAloudPlayerSourceState,
         bgMode: Int,
@@ -212,14 +239,7 @@ class ReadAloudPlayerViewModel(
         val activeIndex = source.textLines.indexOfLast {
             it.chapterPosition <= source.chapterPosition
         }
-        val chapters = source.chapters.map { chapter ->
-            PlayerChapterUi(
-                index = chapter.index,
-                title = chapter.title,
-                isVolume = chapter.isVolume,
-                tocLevel = chapter.tocLevel,
-            )
-        }.toImmutableList()
+        val chapters = chaptersOf(source.chapters)
         return ReadAloudPlayerUiState(
             bookUrl = source.bookUrl,
             bookName = source.bookName,

+ 294 - 114
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -24,11 +24,17 @@ import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -44,10 +50,14 @@ import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.constant.AppConst.appInfo
+import io.legado.app.core.ui.player.playerUnderlaySemantics
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
@@ -59,10 +69,16 @@ import io.legado.app.service.WebService
 import io.legado.app.ui.about.MarkdownSheet
 import io.legado.app.ui.about.UpdateDialog
 import io.legado.app.ui.book.audio.AudioPlayViewModel
+import io.legado.app.ui.book.read.ReadAloudControlsRequestBus
 import io.legado.app.ui.book.read.ReadBookInputHandler
 import io.legado.app.ui.book.read.ReadBookRouteHost
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.ui.book.readaloud.ReadAloudPlayerMorphHost
+import io.legado.app.ui.book.readaloud.ReadAloudPlayerOverlayBus
 import io.legado.app.ui.book.readaloud.ReadAloudShellHost
+import io.legado.app.ui.book.readaloud.morph.CapsuleAnchorKind
+import io.legado.app.ui.book.readaloud.morph.LocalReadAloudMorph
+import io.legado.app.ui.book.readaloud.morph.rememberReadAloudMorphState
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfCoverPreloader
 import io.legado.app.ui.theme.LocalAppUiConfiguration
@@ -77,8 +93,11 @@ import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import org.koin.android.ext.android.inject
 import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -374,6 +393,10 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
         setIntent(intent)
+        if (intent.getBooleanExtra(MainIntent.EXTRA_OPEN_READ_ALOUD_PLAYER, false)) {
+            ReadAloudPlayerOverlayBus.request()
+            return
+        }
         if (intent.getBooleanExtra(MainIntent.EXTRA_WEB_SERVICE_LOCAL_NETWORK, false)) {
             startWebServiceWithLocalNetworkPermission()
             return
@@ -418,6 +441,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
             val resolved = MainNavigator.resolveStartRoute(intent)
             val hasExplicitStartRoute = intent?.hasExplicitStartRoute() == true
             when {
+                resolved is MainRouteAudioPlay -> arrayOf(MainRouteHome)
                 MainIntent.shouldOpenRouteWithHomeParent(intent) -> {
                     if (resolved == MainRouteHome) {
                         arrayOf(MainRouteHome)
@@ -453,18 +477,120 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
         val pageShellShowCapsule = pageShellAloudSettings.showReadAloudCapsule
         val pageShellCapsuleScope = rememberCoroutineScope()
 
+        // 两种播放页共享同窗口形变容器,导航栈保留原页面作为动画背景。
+        val initialAudioRoute =
+            remember { MainNavigator.resolveStartRoute(intent) as? MainRouteAudioPlay }
+        var initialAudioHandled by rememberSaveable { mutableStateOf(false) }
+        var readAloudPlayerVisible by rememberSaveable {
+            mutableStateOf(
+                initialAudioRoute == null &&
+                        intent.getBooleanExtra(MainIntent.EXTRA_OPEN_READ_ALOUD_PLAYER, false)
+            )
+        }
+        var audioPlayerVisible by rememberSaveable { mutableStateOf(initialAudioRoute != null) }
+        var audioPlayerBookUrl by rememberSaveable { mutableStateOf(initialAudioRoute?.bookUrl.orEmpty()) }
+        var audioPlayerInBookshelf by rememberSaveable {
+            mutableStateOf(
+                initialAudioRoute?.inBookshelf ?: true
+            )
+        }
+        var playerSource by rememberSaveable {
+            mutableStateOf(if (initialAudioRoute != null) PlaybackCapsuleSource.AudioBook else PlaybackCapsuleSource.ReadAloud)
+        }
+        val readAloudMorph = rememberReadAloudMorphState()
+        val playbackGateway: PlaybackCapsuleGateway = org.koin.compose.koinInject()
+        val playbackCapsuleState by playbackGateway.state.collectAsStateWithLifecycle()
+        val playerOpenMutex = remember { Mutex() }
+        val morphPresent by remember { derivedStateOf { readAloudMorph.progress.value > 0f } }
+
+        suspend fun openPlayer(request: PlaybackCapsuleState) = playerOpenMutex.withLock {
+            val source = request.source ?: PlaybackCapsuleSource.ReadAloud
+            val switchingPlayer = playerSource != source ||
+                    (source == PlaybackCapsuleSource.AudioBook && audioPlayerBookUrl != request.bookUrl)
+            if (switchingPlayer && readAloudMorph.progress.value > 0f) {
+                // 先交回起点,防止两个宿主同时驱动同一进度,或飞行中途换封面。
+                readAloudPlayerVisible = false
+                audioPlayerVisible = false
+                // 当前宿主响应 visible=false 完成收起,避免两个协程争用 Animatable。
+                snapshotFlow { readAloudMorph.progress.value }.first { it <= 0f }
+            }
+            playerSource = source
+            if (source == PlaybackCapsuleSource.AudioBook) {
+                audioPlayerBookUrl = request.bookUrl
+                audioPlayerInBookshelf = request.inBookshelf
+                audioPlayerVisible = true
+                readAloudPlayerVisible = false
+            } else {
+                readAloudPlayerVisible = true
+                audioPlayerVisible = false
+            }
+        }
+        LaunchedEffect(Unit) {
+            if (!initialAudioHandled) {
+                initialAudioHandled = true
+                initialAudioRoute?.let { route ->
+                    openPlayer(
+                        PlaybackCapsuleState(
+                            source = PlaybackCapsuleSource.AudioBook,
+                            bookUrl = route.bookUrl.orEmpty(),
+                            inBookshelf = route.inBookshelf,
+                        )
+                    )
+                }
+            }
+            ReadAloudPlayerOverlayBus.events.collect { openPlayer(it) }
+        }
+        val playerVisible = readAloudPlayerVisible || audioPlayerVisible
+        val matchingCapsule = !playerVisible || capsuleMatchesPlayer(
+            playbackCapsuleState, playerSource, audioPlayerBookUrl,
+        )
+        SideEffect {
+            readAloudMorph.reportCapsuleCoverLinked(
+                playbackCapsuleState.source == null || matchingCapsule,
+            )
+            if (!pageShellShowCapsule) readAloudMorph.clearStartAnchors()
+        }
+        // 只用于首帧锚点布局,不把尚未启动的朗读伪装成正在播放。
+        val capsuleAnchorPreview = if (playerVisible && pageShellShowCapsule) {
+            PlaybackCapsuleState(source = playerSource)
+        } else null
+        // 两种胶囊互斥:主页悬浮底栏用圆形胶囊,其余页面用全局可拖拽胶囊。
+        val currentRoute = backStack.lastOrNull()
+        val onMainRoute = currentRoute is MainRouteHome
+        val useFloatingBottomBar = LocalAppUiConfiguration.current.appShell.useFloatingBottomBar
+        val useHomeCapsule = shouldUseHomePlaybackCapsule(
+            onMainRoute, configuration.appShell.showBottomView, useFloatingBottomBar, useRail,
+        )
+        SideEffect {
+            readAloudMorph.expectCapsuleAnchors(
+                if (!pageShellShowCapsule) null
+                else if (useHomeCapsule) CapsuleAnchorKind.HomeBar else CapsuleAnchorKind.Global,
+            )
+        }
+
         SideEffect {
             shouldApplyDefaultToRead = false
         }
 
         LaunchedEffect(backStack) {
             routeEvents.collect { event ->
-                MainNavigator.navigateToRoute(
-                    backStack = backStack,
-                    route = event.route,
-                    tracker = navRouteTracker,
-                    resetToHome = event.resetToHome,
-                )
+                val audioRoute = event.route as? MainRouteAudioPlay
+                if (audioRoute != null) {
+                    openPlayer(
+                        PlaybackCapsuleState(
+                            source = PlaybackCapsuleSource.AudioBook,
+                            bookUrl = audioRoute.bookUrl.orEmpty(),
+                            inBookshelf = audioRoute.inBookshelf,
+                        )
+                    )
+                } else {
+                    MainNavigator.navigateToRoute(
+                        backStack = backStack,
+                        route = event.route,
+                        tracker = navRouteTracker,
+                        resetToHome = event.resetToHome,
+                    )
+                }
             }
         }
 
@@ -476,129 +602,183 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                     MainNavigator.onBackStackChanged()
                 }
         }
-        // 全局朗读胶囊据此判断听书页是否在最上层
-        val navBackStack by navRouteTracker.backStack.collectAsStateWithLifecycle()
-        val currentRoute = navBackStack.lastOrNull()
-
         SharedTransitionLayout {
             // 启动验证做成单独的 screen:门槛未过时完全不组合应用界面,
             // 因此验证页背后看不到书架/阅读界面,也没有可交互的入口
             PrivateAppStartGate {
                 Box(modifier = Modifier.fillMaxSize()) {
-                    NavDisplay(
-                        backStack = backStack,
-                        entryDecorators = listOf(
-                            rememberSaveableStateHolderNavEntryDecorator(),
-                            rememberViewModelStoreNavEntryDecorator(),
-                        ),
-                        sceneStrategies = listOf(
-                            ModalOverlaySceneStrategy(),
-                            SinglePaneSceneStrategy(),
-                        ),
-                        transitionSpec = {
-                            (slideIntoContainer(
-                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                                animationSpec = tween(
-                                    durationMillis = 480,
-                                    easing = FastOutSlowInEasing
-                                ),
-                                initialOffset = { fullWidth -> fullWidth }
-                            ) + fadeIn(
-                                animationSpec = tween(
-                                    durationMillis = 360,
-                                    easing = LinearOutSlowInEasing
-                                )
-                            )) togetherWith (slideOutOfContainer(
-                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                                animationSpec = tween(
-                                    durationMillis = 480,
-                                    easing = FastOutSlowInEasing
-                                ),
-                                targetOffset = { fullWidth -> fullWidth / 4 }
-                            ) + fadeOut(
-                                animationSpec = tween(
-                                    durationMillis = 360,
-                                    easing = LinearOutSlowInEasing
-                                )
-                            ))
-                        },
-                        popTransitionSpec = {
-                            (slideIntoContainer(
-                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                                animationSpec = tween(
-                                    durationMillis = 480,
-                                    easing = FastOutSlowInEasing
-                                ),
-                                initialOffset = { fullWidth -> -fullWidth / 4 }
-                            ) + fadeIn(
-                                animationSpec = tween(
-                                    durationMillis = 360,
-                                    easing = LinearOutSlowInEasing
-                                )
-                            )) togetherWith (scaleOut(
-                                targetScale = 0.8f,
-                                animationSpec = tween(
-                                    durationMillis = 480,
-                                    easing = FastOutSlowInEasing
-                                )
-                            ) + fadeOut(animationSpec = tween(durationMillis = 360)))
-                        },
-                        predictivePopTransitionSpec = { _ ->
-                            (slideIntoContainer(
-                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                                animationSpec = tween(easing = FastOutSlowInEasing),
-                                initialOffset = { fullWidth -> -fullWidth / 4 }
-                            ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing))) togetherWith (scaleOut(
-                                targetScale = 0.8f,
-                                animationSpec = tween(easing = FastOutSlowInEasing)
-                            ) + fadeOut(animationSpec = tween()))
-                        },
-                        onBack = { MainNavigator.navigateBack(this@MainActivity, backStack) },
-                        entryProvider = mainEntryProvider(
+                    Box(
+                        Modifier
+                            .fillMaxSize()
+                            .playerUnderlaySemantics(playerVisible || morphPresent)
+                    ) {
+                        NavDisplay(
                             backStack = backStack,
-                            configuration = configuration,
-                            showMangaUi = mangaSettings.showMangaUi,
-                            useRail = useRail,
-                            sharedTransitionScope = this@SharedTransitionLayout,
-                            onNavigateToRoute = { route ->
-                                MainNavigator.navigateToRoute(
-                                    backStack,
-                                    route,
-                                    navRouteTracker,
-                                )
+                            entryDecorators = listOf(
+                                rememberSaveableStateHolderNavEntryDecorator(),
+                                rememberViewModelStoreNavEntryDecorator(),
+                            ),
+                            sceneStrategies = listOf(
+                                ModalOverlaySceneStrategy(),
+                                SinglePaneSceneStrategy(),
+                            ),
+                            transitionSpec = {
+                                (slideIntoContainer(
+                                    towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                    animationSpec = tween(
+                                        durationMillis = 480,
+                                        easing = FastOutSlowInEasing
+                                    ),
+                                    initialOffset = { fullWidth -> fullWidth }
+                                ) + fadeIn(
+                                    animationSpec = tween(
+                                        durationMillis = 360,
+                                        easing = LinearOutSlowInEasing
+                                    )
+                                )) togetherWith (slideOutOfContainer(
+                                    towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                    animationSpec = tween(
+                                        durationMillis = 480,
+                                        easing = FastOutSlowInEasing
+                                    ),
+                                    targetOffset = { fullWidth -> fullWidth / 4 }
+                                ) + fadeOut(
+                                    animationSpec = tween(
+                                        durationMillis = 360,
+                                        easing = LinearOutSlowInEasing
+                                    )
+                                ))
                             },
-                            onNavigateBack = {
-                                MainNavigator.navigateBack(
-                                    this@MainActivity,
-                                    backStack,
-                                    navRouteTracker
-                                )
+                            popTransitionSpec = {
+                                (slideIntoContainer(
+                                    towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                    animationSpec = tween(
+                                        durationMillis = 480,
+                                        easing = FastOutSlowInEasing
+                                    ),
+                                    initialOffset = { fullWidth -> -fullWidth / 4 }
+                                ) + fadeIn(
+                                    animationSpec = tween(
+                                        durationMillis = 360,
+                                        easing = LinearOutSlowInEasing
+                                    )
+                                )) togetherWith (scaleOut(
+                                    targetScale = 0.8f,
+                                    animationSpec = tween(
+                                        durationMillis = 480,
+                                        easing = FastOutSlowInEasing
+                                    )
+                                ) + fadeOut(animationSpec = tween(durationMillis = 360)))
                             },
+                            predictivePopTransitionSpec = { _ ->
+                                (slideIntoContainer(
+                                    towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                    animationSpec = tween(easing = FastOutSlowInEasing),
+                                    initialOffset = { fullWidth -> -fullWidth / 4 }
+                                ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing))) togetherWith (scaleOut(
+                                    targetScale = 0.8f,
+                                    animationSpec = tween(easing = FastOutSlowInEasing)
+                                ) + fadeOut(animationSpec = tween()))
+                            },
+                            onBack = { MainNavigator.navigateBack(this@MainActivity, backStack) },
+                            entryProvider = mainEntryProvider(
+                                backStack = backStack,
+                                configuration = configuration,
+                                showMangaUi = mangaSettings.showMangaUi,
+                                useRail = useRail,
+                                sharedTransitionScope = this@SharedTransitionLayout,
+                                onNavigateToRoute = { route ->
+                                    if (route is MainRouteAudioPlay) {
+                                        pageShellCapsuleScope.launch {
+                                            openPlayer(
+                                                PlaybackCapsuleState(
+                                                    source = PlaybackCapsuleSource.AudioBook,
+                                                    bookUrl = route.bookUrl.orEmpty(),
+                                                    inBookshelf = route.inBookshelf,
+                                                )
+                                            )
+                                        }
+                                    } else {
+                                        MainNavigator.navigateToRoute(
+                                            backStack,
+                                            route,
+                                            navRouteTracker
+                                        )
+                                    }
+                                },
+                                onNavigateBack = {
+                                    MainNavigator.navigateBack(
+                                        this@MainActivity,
+                                        backStack,
+                                        navRouteTracker
+                                    )
+                                },
+                                readAloudMorph = readAloudMorph.takeIf { useHomeCapsule },
+                                homePlaybackCapsuleEnabled = useHomeCapsule,
+                                capsuleAnchorPreview = capsuleAnchorPreview,
+                            )
                         )
-                    )
-                    // 朗读悬浮胶囊叠在整个导航之上:阅读器只是其中一个目的地,
-                    // 挂在阅读器里会导致离开阅读界面后胶囊消失。
-                    ReadAloudShellHost(
+                        // 主页开启悬浮底栏时使用圆形胶囊,其余页面使用全局可拖拽胶囊。
+                        // 隐藏的胶囊不再上报形变锚点。
+                        CompositionLocalProvider(
+                            LocalReadAloudMorph provides readAloudMorph.takeIf { !useHomeCapsule }
+                        ) {
+                            ReadAloudShellHost(
+                                showCapsule = pageShellShowCapsule,
+                                hidden = useHomeCapsule,
+                                anchorPreview = capsuleAnchorPreview,
+                                onCapsulePositionChanged = { x, y ->
+                                    pageShellCapsuleScope.launch {
+                                        readAloudSettingsRepository.putCapsulePosition(x, y)
+                                    }
+                                },
+                                onOpenPlayer = { ReadAloudPlayerOverlayBus.request(it) },
+                            )
+                        }
+                    }
+                    // 听书播放页:同窗口 morph 面板,从胶囊位置长出来。
+                    if (playerSource == PlaybackCapsuleSource.ReadAloud) ReadAloudPlayerMorphHost(
+                        playerViewModel = pageShellPlayerViewModel,
                         playerState = pageShellPlayerState,
-                        showCapsule = pageShellShowCapsule,
-                        hidden = currentRoute is MainRouteReadAloudPlayer,
-                        onIntent = pageShellPlayerViewModel::onIntent,
-                        onCapsulePositionChanged = { x, y ->
-                            pageShellCapsuleScope.launch {
-                                readAloudSettingsRepository.putCapsulePosition(x, y)
+                        morph = readAloudMorph,
+                        visible = readAloudPlayerVisible,
+                        awaitCapsuleAnchor = pageShellShowCapsule,
+                        predictiveBackEnabled = configuration.appShell.predictiveBackEnabled,
+                        onDismiss = { readAloudPlayerVisible = false },
+                        onSwitchToClassic = { bookUrl ->
+                            // 栈顶是阅读界面:让它直接落在经典朗读控制页;
+                            // 否则没有可用的阅读界面,打开一个新的。
+                            if (isReaderOnTop(backStack)) {
+                                ReadAloudControlsRequestBus.request()
+                            } else {
+                                MainNavigator.navigateToRoute(
+                                    backStack,
+                                    MainRouteReadBook(bookUrl = bookUrl.ifBlank { null }),
+                                    navRouteTracker,
+                                )
                             }
                         },
-                        onOpenPlayer = {
-                            MainNavigator.navigateToRoute(
-                                backStack,
-                                MainRouteReadAloudPlayer,
-                                navRouteTracker
-                            )
-                        },
                     )
+                    if (playerSource == PlaybackCapsuleSource.AudioBook &&
+                        (audioPlayerVisible || morphPresent)
+                    ) {
+                        key(audioPlayerBookUrl, audioPlayerInBookshelf) {
+                            AudioPlayerMorphOverlay(
+                                bookUrl = audioPlayerBookUrl,
+                                inBookshelf = audioPlayerInBookshelf,
+                                morph = readAloudMorph,
+                                visible = audioPlayerVisible,
+                                awaitCapsuleAnchor = pageShellShowCapsule,
+                                predictiveBackEnabled = configuration.appShell.predictiveBackEnabled,
+                                onDismiss = { audioPlayerVisible = false },
+                            )
+                        }
+                    }
                 }
                 BackHandler(
-                    enabled = !configuration.appShell.predictiveBackEnabled
+                    enabled = shouldHandleActivityBack(
+                        configuration.appShell.predictiveBackEnabled, playerVisible || morphPresent,
+                    )
                 ) {
                     MainNavigator.navigateBack(this@MainActivity, backStack)
                 }

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -18,6 +18,7 @@ object MainIntent {
     const val EXTRA_CHARACTER_ID = "characterId"
     const val EXTRA_ENTRY_ID = "entryId"
     const val EXTRA_EVENT_ID = "eventId"
+    const val EXTRA_OPEN_READ_ALOUD_PLAYER = "openReadAloudPlayer"
     const val EXTRA_READ_ALOUD = "readAloud"
     const val EXTRA_IN_BOOKSHELF = "inBookshelf"
     const val EXTRA_CHAPTER_CHANGED = "chapterChanged"

+ 206 - 219
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.main
 import android.app.Activity
 import android.content.Intent
 import android.net.Uri
-import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContentTransitionScope
@@ -15,9 +14,8 @@ 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.slideInVertically
-import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -26,11 +24,11 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
@@ -39,14 +37,15 @@ import androidx.navigation3.runtime.entryProvider
 import androidx.navigation3.runtime.metadata
 import androidx.navigation3.ui.LocalNavAnimatedContentScope
 import androidx.navigation3.ui.NavDisplay
-import coil3.ImageLoader
 import io.legado.app.R
 import io.legado.app.constant.BookType
-import io.legado.app.constant.Status
+import io.legado.app.core.ui.player.PlayerMorphAppearance
+import io.legado.app.core.ui.player.PlayerMorphHost
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
 import io.legado.app.domain.model.settings.AppUiConfiguration
 import io.legado.app.feature.reader.platform.ReaderPerfTrace
-import io.legado.app.help.coil.CoverExtras
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.Download
 import io.legado.app.model.ReadAloudSessionStore
@@ -90,7 +89,6 @@ import io.legado.app.ui.book.knowledge.saveCharacterAvatar
 import io.legado.app.ui.book.manage.BookshelfManageRouteScreen
 import io.legado.app.ui.book.manga.MangaReaderRouteScreen
 import io.legado.app.ui.book.manga.MangaReaderViewModel
-import io.legado.app.ui.book.read.ReadAloudControlsRequestBus
 import io.legado.app.ui.book.read.ReadBookController
 import io.legado.app.ui.book.read.ReadBookInitRequest
 import io.legado.app.ui.book.read.ReadBookIntent
@@ -99,6 +97,7 @@ import io.legado.app.ui.book.read.ReadBookViewModel
 import io.legado.app.ui.book.read.ReaderSessionViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordOverviewRouteScreen
 import io.legado.app.ui.book.readRecord.ReadRecordRouteScreen
+import io.legado.app.ui.book.readaloud.ReadAloudPlayerOverlayBus
 import io.legado.app.ui.book.readaloud.cache.TtsCacheRouteScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingViewModel
@@ -106,7 +105,8 @@ import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsEffect
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsIntent
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsScreen
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerRouteScreen
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import io.legado.app.ui.book.readaloud.player.rememberPlayerThemeOverride
 import io.legado.app.ui.book.search.SearchIntent
 import io.legado.app.ui.book.search.SearchRouteScreen
 import io.legado.app.ui.book.search.SearchViewModel
@@ -153,9 +153,6 @@ import io.legado.app.ui.rss.source.edit.RssSourceEditRoute
 import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceRouteScreen
 import io.legado.app.ui.rss.subscription.RuleSubRouteScreen
-import io.legado.app.ui.theme.ProvideThemeOverride
-import io.legado.app.ui.theme.rememberImageSeedColor
-import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.privacy.PrivateReadGate
 import io.legado.app.ui.widget.components.privacy.PrivateVerifyGate
@@ -172,7 +169,6 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.koinViewModel
-import org.koin.compose.koinInject
 import org.koin.core.parameter.parametersOf
 
 /**
@@ -238,48 +234,28 @@ private fun readerEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
 }
 
 /**
- * 听书播放页的转场:整页纵向位移,无淡入淡出。
+ * 以底部弹层呈现的目的地(听书播放页、有声书播放页)。
  *
- * 进入自下而上滑入、退出从上往下滑出,上一站在进出期间保持不动
- * (`EnterTransition.None` / `ExitTransition.None`),所以视觉上是「盖上来 / 收下去」
- * 而不是交叉替换。
- *
- * 三个 key 都要覆写:只写 `TransitionKey` / `PopTransitionKey` 时,开启预测性返回的设备
- * 走系统返回手势会落回 `NavDisplay` 默认的横向转场 —— 这正是「退出时还在走默认动画」的原因
- * (与 [webViewEntryMetadata] 同款,它同样覆写三个 key)。
+ * 上一站保持组合在弹层之下(真实背景可见),目的地自身不参与转场:
+ * 进出动画完全由 `AppModalBottomSheet` 负责,导航层只负责栈。
  */
-private fun readAloudPlayerEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
-    put(NavDisplay.TransitionKey) {
-        slideInVertically(
-            animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
-            initialOffsetY = { fullHeight -> fullHeight },
-        ) togetherWith ExitTransition.None
-    }
-    put(NavDisplay.PopTransitionKey) {
-        EnterTransition.None togetherWith
-                slideOutVertically(
-                    animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
-                    targetOffsetY = { fullHeight -> fullHeight },
-                )
-    }
-    if (predictiveBackEnabled) {
-        // 与 PopTransitionKey 同一套位移:系统返回手势不再跟手,只播这段固定时长动画。
+private fun sheetEntryMetadata(): Map<String, Any> =
+    ModalOverlaySceneStrategy.modalOverlay() + metadata {
+        put(NavDisplay.TransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
+        put(NavDisplay.PopTransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
         put(NavDisplay.PredictivePopTransitionKey) { _ ->
-            EnterTransition.None togetherWith
-                    slideOutVertically(
-                        animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
-                        targetOffsetY = { fullHeight -> fullHeight },
-                    )
+            EnterTransition.None togetherWith ExitTransition.None
         }
     }
-}
 
 /**
- * 听书播放页「经典控制」的目标判定:上一站是阅读界面时回到已有阅读界面并打开经典朗读控制,
- * 否则打开阅读界面。抽成纯函数以便单测覆盖这条分支。
+ * 听书播放弹层「经典控制」的目标判定:栈顶是阅读界面时把请求交给它,
+ * 否则新开阅读界面。抽成纯函数以便单测覆盖这条分支。
+ *
+ * 播放弹层不在导航栈上,所以判据是「当前顶层」而不是「上一站」。
  */
-internal fun hasReadBookParent(backStack: List<NavKey>): Boolean =
-    backStack.dropLast(1).lastOrNull() is MainRouteReadBook
+internal fun isReaderOnTop(backStack: List<NavKey>): Boolean =
+    backStack.lastOrNull() is MainRouteReadBook
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 fun MainActivity.mainEntryProvider(
@@ -290,6 +266,15 @@ fun MainActivity.mainEntryProvider(
     sharedTransitionScope: SharedTransitionScope,
     onNavigateToRoute: (NavKey) -> Unit,
     onNavigateBack: () -> Unit,
+    /**
+     * 「胶囊 ↔ 听书播放页」的形变状态。
+     *
+     * 主界面要把听书胶囊排在悬浮底栏同一行里,需要把胶囊的锚点上报给同一个进度源,
+     * 所以这里透传(其余目的地不用)。
+     */
+    readAloudMorph: ReadAloudMorphState?,
+    homePlaybackCapsuleEnabled: Boolean,
+    capsuleAnchorPreview: io.legado.app.domain.model.PlaybackCapsuleState?,
 ) = entryProvider {
     entry<MainRouteWebView>(
         metadata = webViewEntryMetadata(configuration.appShell.predictiveBackEnabled)
@@ -433,6 +418,9 @@ fun MainActivity.mainEntryProvider(
             onIntent = mainViewModel::onIntent,
             effects = mainViewModel.effects,
             useRail = useRail,
+            readAloudMorph = readAloudMorph,
+            homePlaybackCapsuleEnabled = homePlaybackCapsuleEnabled,
+            capsuleAnchorPreview = capsuleAnchorPreview,
             onOpenSettings = {
                 onNavigateToRoute(MainRouteSettings)
             },
@@ -854,11 +842,6 @@ fun MainActivity.mainEntryProvider(
                 onOpenTtsCache = {
                     onNavigateToRoute(MainRouteTtsCache)
                 },
-                // 经典控制面板的「切换到听书播放器」:目的地是 nav3 路由,
-                // 必须在这里接上,否则意图只发到效果层、没人导航。
-                onOpenReadAloudPlayer = {
-                    onNavigateToRoute(MainRouteReadAloudPlayer)
-                },
             )
 
             DisposableEffect(controller, lifecycleOwner, route.readAloud) {
@@ -950,152 +933,24 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
-    entry<MainRouteAudioPlay>(
-        metadata = readerEntryMetadata(configuration.appShell.predictiveBackEnabled)
-    ) { route ->
-        val audioPlayViewModel = koinViewModel<AudioPlayViewModel>(
-            key = "AudioPlay:${route.bookUrl}",
-        )
-        val lifecycleOwner = LocalLifecycleOwner.current
-        val density = LocalDensity.current.density
-        val platformCapabilities = remember(this@mainEntryProvider) {
-            AndroidPlatformCapabilities(this@mainEntryProvider)
-        }
-        val uiState by audioPlayViewModel.uiState.collectAsStateWithLifecycle()
-        var showAudioChangeSource by remember { mutableStateOf(false) }
-        val imageLoader: ImageLoader = koinInject()
-        val sourceEditResult = rememberLauncherForActivityResult(
-            ActivityResultContracts.StartActivityForResult()
-        ) {
-            if (it.resultCode == Activity.RESULT_OK) {
-                audioPlayViewModel.onIntent(AudioPlayIntent.SourceEdited)
-            }
-        }
-
-        fun copyAudioPlayUrl() {
-            AudioPlay.book?.let {
-                SourceCallBack.callBackBtn(
-                    this@mainEntryProvider,
-                    SourceCallBack.CLICK_COPY_PLAY_URL,
-                    AudioPlay.bookSource,
-                    it,
-                    AudioPlay.durChapter,
-                    BookType.audio,
-                    result = AudioPlayService.url,
-                ) {
-                    sendToClip(AudioPlayService.url)
-                }
-            }
+    entry<MainRouteReadAloudPlayer>(metadata = sheetEntryMetadata()) {
+        LaunchedEffect(Unit) {
+            ReadAloudPlayerOverlayBus.request(PlaybackCapsuleState(source = PlaybackCapsuleSource.ReadAloud))
+            if (backStack.size > 1) onNavigateBack() else backStack[0] = MainRouteHome
         }
+    }
 
-        fun finishAudioPlay() {
-            if (AudioPlay.inBookshelf) {
-                SourceCallBack.callBackBook(
-                    SourceCallBack.END_READ,
-                    AudioPlay.bookSource,
-                    AudioPlay.book,
-                    AudioPlay.durChapter
+    // 兼容已保存的导航栈。新入口在 MainActivity 直接打开同窗口播放浮层。
+    entry<MainRouteAudioPlay>(metadata = sheetEntryMetadata()) { route ->
+        LaunchedEffect(route) {
+            ReadAloudPlayerOverlayBus.request(
+                PlaybackCapsuleState(
+                    source = PlaybackCapsuleSource.AudioBook,
+                    bookUrl = route.bookUrl.orEmpty(),
+                    inBookshelf = route.inBookshelf,
                 )
-            }
-            onNavigateBack()
-        }
-
-        LaunchedEffect(route, audioPlayViewModel) {
-            audioPlayViewModel.onIntent(
-                AudioPlayIntent.Init(route.bookUrl.orEmpty(), route.inBookshelf)
-            )
-        }
-        DisposableEffect(audioPlayViewModel, lifecycleOwner) {
-            MainActivity.hasActiveAudioPlayRoute = true
-            this@mainEntryProvider.activeAudioPlayViewModel = audioPlayViewModel
-            AudioPlay.register(this@mainEntryProvider)
-            onDispose {
-                if (AudioPlay.status != Status.PLAY) {
-                    AudioPlay.stop()
-                }
-                AudioPlay.unregister(this@mainEntryProvider)
-                if (this@mainEntryProvider.activeAudioPlayViewModel === audioPlayViewModel) {
-                    this@mainEntryProvider.activeAudioPlayViewModel = null
-                }
-                MainActivity.hasActiveAudioPlayRoute = false
-            }
-        }
-        LaunchedEffect(audioPlayViewModel) {
-            audioPlayViewModel.effects.collectLatest { effect ->
-                when (effect) {
-                    is AudioPlayEffect.OpenChangeSource -> showAudioChangeSource = true
-
-                    is AudioPlayEffect.OpenLogin -> startActivity(
-                        MainActivity.createSourceLoginIntent(
-                            this@mainEntryProvider,
-                            SourceLoginType.BookSource,
-                            effect.sourceUrl
-                        )
-                    )
-
-                    AudioPlayEffect.CopyPlayUrl -> copyAudioPlayUrl()
-                    is AudioPlayEffect.OpenEditSource -> sourceEditResult.launch(
-                        MainActivity.createBookSourceEditIntent(
-                            this@mainEntryProvider,
-                            effect.sourceUrl
-                        )
-                    )
-
-                    is AudioPlayEffect.ShowToast -> toastOnUi(effect.message)
-                    is AudioPlayEffect.OpenBookReader -> {
-                        startActivity(
-                            MainActivity.createReadBookIntent(
-                                this@mainEntryProvider,
-                                effect.bookUrl
-                            )
-                        )
-                        onNavigateBack()
-                    }
-
-                    AudioPlayEffect.Finish -> finishAudioPlay()
-                }
-            }
-        }
-        BackHandler {
-            audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed)
-        }
-
-        // 封面动态取色:从封面提取主色作为本界面主题
-        val seedColor = rememberImageSeedColor(
-            imageLoader = imageLoader,
-            data = uiState.coverPath,
-            requestKey = listOf(uiState.coverPath, uiState.sourceOrigin),
-        ) {
-            extras[CoverExtras.SourceOrigin] = uiState.sourceOrigin
-        }
-        val themeOverride = rememberThemeOverride(seedColor)
-        ProvideThemeOverride(themeOverride) {
-            AudioPlayScreenContent(
-                state = uiState,
-                onIntent = audioPlayViewModel::onIntent,
-                onBack = { audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed) },
-                modifier = Modifier.readerSharedBounds(
-                    sharedTransitionScope,
-                    LocalNavAnimatedContentScope.current,
-                    route.sharedCoverKey,
-                    platformCapabilities.displayCornerRadiusPx,
-                    density,
-                ),
-            )
-        }
-        val audioBook = AudioPlay.book
-        if (showAudioChangeSource && audioBook != null) {
-            ChangeSourceSheet(
-                show = true,
-                oldBook = audioBook,
-                onDismissRequest = { showAudioChangeSource = false },
-                onReplace = { source, book, toc, _ ->
-                    audioPlayViewModel.changeTo(source, book, toc)
-                },
-                onAddAsNew = { book, toc ->
-                    audioPlayViewModel.addToBookshelf(book, toc)
-                },
             )
+            if (backStack.size > 1) onNavigateBack() else backStack[0] = MainRouteHome
         }
     }
 
@@ -1510,30 +1365,6 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
-    entry<MainRouteReadAloudPlayer>(
-        metadata = readAloudPlayerEntryMetadata(configuration.appShell.predictiveBackEnabled)
-    ) {
-        // 听书播放界面独立于阅读器:从胶囊或媒体按键打开时,阅读器可能根本不在栈上,
-        // 因此这里不复用阅读器的状态宿主,只依赖全局朗读会话状态。
-        var readAloudConfigOpen by rememberSaveable { mutableStateOf(false) }
-        ReadAloudPlayerRouteScreen(
-            showReadAloudConfig = readAloudConfigOpen,
-            onReadAloudConfigVisibleChange = { readAloudConfigOpen = it },
-            onBack = { onNavigateBack() },
-            onSwitchToClassic = { bookUrl ->
-                // 上级是阅读界面(从阅读界面进入听书页):回退到它,并让它直接落在经典朗读控制页。
-                // 上级不是阅读界面(胶囊/媒体键在任意界面之上打开):没有可回的阅读界面,
-                // 改为打开阅读界面。
-                if (hasReadBookParent(backStack)) {
-                    ReadAloudControlsRequestBus.request()
-                    onNavigateBack()
-                } else {
-                    onNavigateToRoute(MainRouteReadBook(bookUrl = bookUrl.ifBlank { null }))
-                }
-            },
-        )
-    }
-
     entry<MainRouteBookKnowledgeList> { route ->
         val viewModel = koinViewModel<BookKnowledgeListViewModel>(
             key = "KnowledgeList:${route.bookUrl}",
@@ -1653,3 +1484,159 @@ fun MainActivity.mainEntryProvider(
         )
     }
 }
+
+/** 有声书的 Android 宿主动作;播放页与听书页共用同窗口形变组件。 */
+@Composable
+internal fun MainActivity.AudioPlayerMorphOverlay(
+    bookUrl: String,
+    inBookshelf: Boolean,
+    morph: ReadAloudMorphState,
+    visible: Boolean,
+    awaitCapsuleAnchor: Boolean,
+    predictiveBackEnabled: Boolean,
+    onDismiss: () -> Unit,
+) {
+    // 浮层关闭后释放页面 VM;播放服务和会话继续保留,重新打开会重新同步书籍。
+    val playerStoreOwner = remember {
+        object : ViewModelStoreOwner {
+            override val viewModelStore = ViewModelStore()
+        }
+    }
+    DisposableEffect(playerStoreOwner) {
+        onDispose { playerStoreOwner.viewModelStore.clear() }
+    }
+    val audioPlayViewModel = koinViewModel<AudioPlayViewModel>(
+        key = "AudioPlay:${bookUrl}",
+        viewModelStoreOwner = playerStoreOwner,
+    )
+    val lifecycleOwner = LocalLifecycleOwner.current
+    val uiState by audioPlayViewModel.uiState.collectAsStateWithLifecycle()
+    var showAudioChangeSource by remember { mutableStateOf(false) }
+    val sourceEditResult = rememberLauncherForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) {
+        if (it.resultCode == Activity.RESULT_OK) {
+            audioPlayViewModel.onIntent(AudioPlayIntent.SourceEdited)
+        }
+    }
+
+    fun copyAudioPlayUrl() {
+        AudioPlay.book?.let {
+            SourceCallBack.callBackBtn(
+                this@AudioPlayerMorphOverlay,
+                SourceCallBack.CLICK_COPY_PLAY_URL,
+                AudioPlay.bookSource,
+                it,
+                AudioPlay.durChapter,
+                BookType.audio,
+                result = AudioPlayService.url,
+            ) {
+                sendToClip(AudioPlayService.url)
+            }
+        }
+    }
+
+    fun finishAudioPlay() {
+        if (AudioPlay.inBookshelf) {
+            SourceCallBack.callBackBook(
+                SourceCallBack.END_READ,
+                AudioPlay.bookSource,
+                AudioPlay.book,
+                AudioPlay.durChapter
+            )
+        }
+        onDismiss()
+    }
+
+    LaunchedEffect(bookUrl, inBookshelf, audioPlayViewModel) {
+        audioPlayViewModel.onIntent(
+            AudioPlayIntent.Init(bookUrl.orEmpty(), inBookshelf)
+        )
+    }
+    DisposableEffect(audioPlayViewModel, lifecycleOwner) {
+        MainActivity.hasActiveAudioPlayRoute = true
+        this@AudioPlayerMorphOverlay.activeAudioPlayViewModel = audioPlayViewModel
+        AudioPlay.register(this@AudioPlayerMorphOverlay)
+        onDispose {
+            AudioPlay.unregister(this@AudioPlayerMorphOverlay)
+            if (this@AudioPlayerMorphOverlay.activeAudioPlayViewModel === audioPlayViewModel) {
+                this@AudioPlayerMorphOverlay.activeAudioPlayViewModel = null
+            }
+            MainActivity.hasActiveAudioPlayRoute = false
+        }
+    }
+    LaunchedEffect(audioPlayViewModel) {
+        audioPlayViewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is AudioPlayEffect.OpenChangeSource -> showAudioChangeSource = true
+
+                is AudioPlayEffect.OpenLogin -> startActivity(
+                    MainActivity.createSourceLoginIntent(
+                        this@AudioPlayerMorphOverlay,
+                        SourceLoginType.BookSource,
+                        effect.sourceUrl
+                    )
+                )
+
+                AudioPlayEffect.CopyPlayUrl -> copyAudioPlayUrl()
+                is AudioPlayEffect.OpenEditSource -> sourceEditResult.launch(
+                    MainActivity.createBookSourceEditIntent(
+                        this@AudioPlayerMorphOverlay,
+                        effect.sourceUrl
+                    )
+                )
+
+                is AudioPlayEffect.ShowToast -> toastOnUi(effect.message)
+                is AudioPlayEffect.OpenBookReader -> {
+                    startActivity(
+                        MainActivity.createReadBookIntent(
+                            this@AudioPlayerMorphOverlay,
+                            effect.bookUrl
+                        )
+                    )
+                    onDismiss()
+                }
+
+                AudioPlayEffect.Finish -> {
+                    morph.animateTo(0f)
+                    finishAudioPlay()
+                }
+            }
+        }
+    }
+    PlayerMorphHost(
+        appearance = PlayerMorphAppearance(
+            uiState.bookName, uiState.author, uiState.coverPath,
+            uiState.sourceOrigin, uiState.bgMode,
+        ),
+        playerTheme = rememberPlayerThemeOverride(
+            uiState.bookName, uiState.author, uiState.coverPath, uiState.sourceOrigin,
+        ),
+        morph = morph,
+        visible = visible,
+        awaitCapsuleAnchor = awaitCapsuleAnchor,
+        predictiveBackEnabled = predictiveBackEnabled,
+        backEnabled = uiState.activeSheet == null && !showAudioChangeSource,
+        onDismiss = ::finishAudioPlay,
+    ) { onCollapse ->
+        AudioPlayScreenContent(
+            state = uiState,
+            onIntent = audioPlayViewModel::onIntent,
+            onBack = onCollapse,
+        )
+    }
+    val audioBook = AudioPlay.book
+    if (showAudioChangeSource && audioBook != null) {
+        ChangeSourceSheet(
+            show = true,
+            oldBook = audioBook,
+            onDismissRequest = { showAudioChangeSource = false },
+            onReplace = { source, book, toc, _ ->
+                audioPlayViewModel.changeTo(source, book, toc)
+            },
+            onAddAsNew = { book, toc ->
+                audioPlayViewModel.addToBookshelf(book, toc)
+            },
+        )
+    }
+}

+ 4 - 7
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -10,6 +10,10 @@ sealed interface MainRoute : NavKey
 @Serializable
 data object MainRouteHome : MainRoute
 
+/** 只兼容旧版本保存的导航栈,新入口使用 Activity 播放浮层。 */
+@Serializable
+data object MainRouteReadAloudPlayer : MainRoute
+
 @Serializable
 data class MainRouteSourceLogin(
     val type: SourceLoginType,
@@ -155,13 +159,6 @@ data class MainRouteAudioPlay(
     val sharedCoverKey: String? = null,
 ) : MainRoute
 
-/**
- * 听书播放界面。单例语义:可从阅读界面、悬浮胶囊或媒体按键在任意界面之上打开,
- * 重复进入只替换不叠加。
- */
-@Serializable
-data object MainRouteReadAloudPlayer : MainRoute
-
 @Serializable
 data class MainRouteSearch(
     val key: String?,

+ 2 - 12
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -88,6 +88,8 @@ object MainNavigator {
             is MainRouteBookSourceDebug,
             is MainRouteRssSourceDebug -> backStack.add(route)
 
+            MainRouteReadAloudPlayer -> backStack.add(route)
+
             MainRouteHome -> {
                 backStack.clear()
                 backStack.add(MainRouteHome)
@@ -178,18 +180,6 @@ object MainNavigator {
                 backStack.add(route)
             }
 
-            MainRouteReadAloudPlayer -> {
-                // 单例语义:已在栈上则回到那一层,避免重复按媒体键叠出多个播放界面
-                val existingPlayerIndex = backStack.indexOfLast { it is MainRouteReadAloudPlayer }
-                if (existingPlayerIndex >= 0) {
-                    while (backStack.lastIndex > existingPlayerIndex) {
-                        backStack.removeAt(backStack.lastIndex)
-                    }
-                } else {
-                    backStack.add(route)
-                }
-            }
-
             is MainRouteSearch -> {
                 if (
                     currentRoute == MainRouteHome ||

+ 98 - 21
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -6,12 +6,16 @@ import androidx.activity.ComponentActivity
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
@@ -22,8 +26,10 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material.icons.Icons
@@ -51,6 +57,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -76,6 +83,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
+import io.legado.app.ui.book.readaloud.ReadAloudBarCapsuleEndPadding
+import io.legado.app.ui.book.readaloud.ReadAloudBarCapsuleSize
+import io.legado.app.ui.book.readaloud.ReadAloudBarCapsuleSlot
+import io.legado.app.ui.book.readaloud.ReadAloudPlayerOverlayBus
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.main.bookshelf.BookshelfRouteScreen
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -110,6 +122,8 @@ import top.yukonga.miuix.kmp.basic.rememberNavigationRailState
 import top.yukonga.miuix.kmp.basic.NavigationRail as MiuixNavigationRail
 import top.yukonga.miuix.kmp.basic.NavigationRailItem as MiuixNavigationRailItem
 
+private val MainNavigationTabMinWidth = 76.dp
+
 @OptIn(
     ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class,
     ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class
@@ -120,6 +134,10 @@ fun MainScreen(
     onIntent: (MainUiIntent) -> Unit,
     effects: kotlinx.coroutines.flow.Flow<MainEffect>,
     useRail: Boolean,
+    /** 听书胶囊的形变状态:胶囊要排在悬浮底栏同一行,需要把锚点上报给同一个进度源。 */
+    readAloudMorph: ReadAloudMorphState?,
+    homePlaybackCapsuleEnabled: Boolean,
+    capsuleAnchorPreview: io.legado.app.domain.model.PlaybackCapsuleState?,
     onOpenSettings: () -> Unit,
     onNavigateToChat: () -> Unit,
     onNavigateToSearch: (String?) -> Unit,
@@ -236,7 +254,10 @@ fun MainScreen(
     val labelVisibilityMode = mainUiState.labelVisibilityMode
     val isUnlabeled = labelVisibilityMode == "unlabeled"
     val useFloatingBottomBar =
-        !useRail && mainUiState.showBottomView && mainUiState.useFloatingBottomBar
+        shouldUseHomePlaybackCapsule(
+            onMainRoute = true, showBottomView = mainUiState.showBottomView,
+            useFloatingBottomBar = mainUiState.useFloatingBottomBar, useRail = useRail,
+        )
     val useLiquidGlass = useFloatingBottomBar &&
             mainUiState.useFloatingBottomBarLiquidGlass &&
             Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@@ -603,26 +624,60 @@ fun MainScreen(
                     }
                 }
 
-                if (!useRail && mainUiState.showBottomView && useFloatingBottomBar) {
-                    Box(modifier = Modifier
-                        .align(Alignment.BottomCenter)
-                        .fillMaxWidth()
+                if (useFloatingBottomBar) {
+                    var readAloudCapsuleShown by remember { mutableStateOf(false) }
+                    var playbackControlsExpanded by rememberSaveable { mutableStateOf(false) }
+                    val expansion by animateFloatAsState(
+                        targetValue = if (playbackControlsExpanded && readAloudCapsuleShown) 1f else 0f,
+                        animationSpec = spring(
+                            dampingRatio = 1f,
+                            stiffness = 260f,
+                            visibilityThreshold = 0.0001f
+                        ),
+                        label = "homePlaybackControls",
+                    )
+                    BoxWithConstraints(
+                        modifier = Modifier
+                            .align(Alignment.BottomCenter)
+                            .fillMaxWidth()
+                            .padding(horizontal = 16.dp)
+                            .padding(
+                                bottom = 12.dp + WindowInsets.navigationBars.asPaddingValues()
+                                    .calculateBottomPadding()
+                            )
                     ) {
+                        val gap = ReadAloudBarCapsuleEndPadding
+                        val capsuleReserve by animateDpAsState(
+                            targetValue = if (readAloudCapsuleShown) ReadAloudBarCapsuleSize + gap else 0.dp,
+                            // 入场前立即留出空间;退场后逐步归还,避免停播时导航宽度跳变。
+                            animationSpec = if (readAloudCapsuleShown) snap() else spring(
+                                dampingRatio = 1f,
+                                stiffness = 260f
+                            ),
+                            label = "homePlaybackReserve",
+                        )
+                        val navigationAvailableWidth =
+                            (maxWidth - capsuleReserve).coerceAtLeast(0.dp)
+                        // 保留原导航项的自然宽度和居中位置,不把没有胶囊的底栏拉满。
+                        val normalNavigationWidth =
+                            (MainNavigationTabMinWidth * destinations.size + 8.dp)
+                                .coerceAtMost(navigationAvailableWidth)
+                        val navigationStartInset =
+                            (navigationAvailableWidth - normalNavigationWidth) / 2
+                        val expandedCapsuleWidth = (maxWidth - ReadAloudBarCapsuleSize - gap)
+                            .coerceAtLeast(ReadAloudBarCapsuleSize)
+                        val capsuleWidth = ReadAloudBarCapsuleSize +
+                                (expandedCapsuleWidth - ReadAloudBarCapsuleSize) * expansion
+                        val navigationWidth = normalNavigationWidth +
+                                (ReadAloudBarCapsuleSize.coerceAtMost(maxWidth) - normalNavigationWidth) * expansion
                         FloatingBottomBar(
                             modifier = Modifier
-                                .align(Alignment.BottomCenter)
-                                .clickable(
-                                    interactionSource = remember { MutableInteractionSource() },
-                                    indication = null,
-                                    onClick = {}
-                                )
-                                .padding(
-                                    start = 16.dp,
-                                    end = 16.dp,
-                                    bottom = 12.dp + WindowInsets.navigationBars
-                                        .asPaddingValues()
-                                        .calculateBottomPadding()
-                                ),
+                                .align(Alignment.BottomStart)
+                                .offset(x = navigationStartInset * (1f - expansion))
+                                .width(navigationWidth),
+                            compactProgress = expansion,
+                            expandedWidth = normalNavigationWidth,
+                            onRestoreNavigation = { playbackControlsExpanded = false },
                             selectedIndex = { pagerState.targetPage },
                             onSelected = { index ->
                                 destinations.getOrNull(index)?.let { destination ->
@@ -649,11 +704,14 @@ fun MainScreen(
                                     mainUiState.selectedCustomIconPath(destination)
                                 val destinationLabel = stringResource(destination.labelId)
                                 FloatingBottomBarItem(
+                                    enabled = !playbackControlsExpanded && expansion <= 0.05f,
                                     onClick = {
                                         handleMainDestinationClick(index, destination)
                                     },
                                     modifier = Modifier
-                                        .defaultMinSize(minWidth = 76.dp)
+                                        .defaultMinSize(
+                                            minWidth = MainNavigationTabMinWidth
+                                        )
                                         .semantics(mergeDescendants = true) {
                                             contentDescription = destinationLabel
                                         }
@@ -665,7 +723,9 @@ fun MainScreen(
                                         } else customIconPath,
                                         selected = selected
                                     )
-                                    if (showLabel && (alwaysShowLabel || selected)) {
+                                    if (
+                                        showLabel && (alwaysShowLabel || selected)
+                                    ) {
                                         AppText(
                                             text = stringResource(destination.labelId),
                                             style = MaterialTheme.typography.labelSmall,
@@ -676,6 +736,23 @@ fun MainScreen(
                                 }
                             }
                         }
+                        // 打开听书播放页:与阅读器共用同一条全局请求通道。
+                        ReadAloudBarCapsuleSlot(
+                            enabled = homePlaybackCapsuleEnabled,
+                            onOpenPlayer = { ReadAloudPlayerOverlayBus.request(it) },
+                            morph = readAloudMorph,
+                            anchorPreview = capsuleAnchorPreview,
+                            // 与悬浮底栏共用同一份 backdrop,液态玻璃开关一致。
+                            backdrop = floatingBarBackdrop,
+                            isBlurEnabled = useLiquidGlass,
+                            controlsExpanded = playbackControlsExpanded,
+                            expansion = expansion,
+                            width = capsuleWidth,
+                            expandedWidth = expandedCapsuleWidth,
+                            onControlsExpandedChange = { playbackControlsExpanded = it },
+                            onOccupiedWidthChanged = { readAloudCapsuleShown = it > 0 },
+                            modifier = Modifier.align(Alignment.BottomEnd),
+                        )
                     }
                 }
             }

+ 27 - 0
app/src/main/java/io/legado/app/ui/main/PlaybackPlayerAnchors.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.main
+
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+
+/** 只有同一播放会话的胶囊才能作为封面飞行起点。不同会话仍使用真实胶囊位置,但封面只淡出交接。 */
+internal fun capsuleMatchesPlayer(
+    capsule: PlaybackCapsuleState,
+    source: PlaybackCapsuleSource,
+    bookUrl: String,
+): Boolean = capsule.source == source &&
+        (source == PlaybackCapsuleSource.ReadAloud || bookUrl.isBlank() || capsule.bookUrl == bookUrl)
+
+/** 与主页底栏的实际组合条件一致,隐藏底栏时由全局胶囊提供锚点。 */
+internal fun shouldUseHomePlaybackCapsule(
+    onMainRoute: Boolean,
+    showBottomView: Boolean,
+    useFloatingBottomBar: Boolean,
+    useRail: Boolean,
+): Boolean = onMainRoute && showBottomView && useFloatingBottomBar && !useRail
+
+/** 播放浮层(包括进出场)优先处理返回,不能弹掉下层页面。 */
+internal fun shouldHandleActivityBack(
+    predictiveBackEnabled: Boolean,
+    playerPresent: Boolean
+): Boolean =
+    !predictiveBackEnabled && !playerPresent

+ 8 - 2
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -21,13 +21,14 @@ import top.yukonga.miuix.kmp.theme.ColorSchemeMode
 fun AppTheme(
     configuration: AppUiConfiguration,
     darkTheme: Boolean = configuration.isDarkTheme,
+    applyBackground: Boolean = true,
     content: @Composable () -> Unit
 ) {
     CompositionLocalProvider(LocalAppUiConfiguration provides configuration) {
         if (LocalInspectionMode.current) {
-            AppThemePreview(darkTheme, content)
+            AppThemePreview(darkTheme, applyBackground, content)
         } else {
-            AppThemeActual(configuration, darkTheme, content)
+            AppThemeActual(configuration, darkTheme, applyBackground, content)
         }
     }
 }
@@ -36,6 +37,7 @@ fun AppTheme(
 @Composable
 private fun AppThemePreview(
     darkTheme: Boolean,
+    applyBackground: Boolean,
     content: @Composable () -> Unit
 ) {
     val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme()
@@ -54,6 +56,7 @@ private fun AppThemePreview(
         MaterialThemeWrapper(
             themeColors = themeColors,
             customFontFamily = null,
+            applyBackground = applyBackground,
             content = content
         )
     }
@@ -64,6 +67,7 @@ private fun AppThemePreview(
 private fun AppThemeActual(
     configuration: AppUiConfiguration,
     darkTheme: Boolean,
+    applyBackground: Boolean,
     content: @Composable () -> Unit
 ) {
     val appShellSettings = configuration.appShell
@@ -171,12 +175,14 @@ private fun AppThemeActual(
             MiuixThemeWrapper(
                 themeColors = themeColors,
                 customFontFamily = customFontFamily,
+                applyBackground = applyBackground,
                 content = content
             )
         } else {
             MaterialThemeWrapper(
                 themeColors = themeColors,
                 customFontFamily = customFontFamily,
+                applyBackground = applyBackground,
                 content = content
             )
         }

+ 4 - 2
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -70,6 +70,7 @@ private fun loadCustomFont(context: Context, fontPath: String): FontFamily? =
 fun MiuixThemeWrapper(
     themeColors: LegadoThemeMode,
     customFontFamily: FontFamily?,
+    applyBackground: Boolean = true,
     content: @Composable () -> Unit
 ) {
     val configuration = LocalAppUiConfiguration.current
@@ -234,7 +235,7 @@ fun MiuixThemeWrapper(
             LocalLegadoTypography provides legadoTypography,
             LocalLegadoColorScheme provides mappedColorScheme
         ) {
-            AppBackground(darkTheme = darkTheme) { content() }
+            if (applyBackground) AppBackground(darkTheme = darkTheme) { content() } else content()
         }
     }
 }
@@ -244,6 +245,7 @@ fun MiuixThemeWrapper(
 fun MaterialThemeWrapper(
     themeColors: LegadoThemeMode,
     customFontFamily: FontFamily?,
+    applyBackground: Boolean = true,
     content: @Composable () -> Unit
 ) {
     val themeSettings = LocalAppUiConfiguration.current.theme
@@ -299,7 +301,7 @@ fun MaterialThemeWrapper(
             LocalLegadoTypography provides legadoTypography,
             LocalLegadoColorScheme provides semanticColors
         ) {
-            AppBackground(darkTheme = darkTheme) { content() }
+            if (applyBackground) AppBackground(darkTheme = darkTheme) { content() } else content()
         }
     }
 }

+ 156 - 84
app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt

@@ -17,6 +17,17 @@ import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.IntrinsicSize
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import io.legado.app.R
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
@@ -73,23 +84,30 @@ import kotlinx.coroutines.launch
 import kotlin.math.abs
 import kotlin.math.sign
 
+private val LocalFloatingBottomBarTabInteractive = staticCompositionLocalOf { true }
+
 val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } }
 
 @Composable
 fun RowScope.FloatingBottomBarItem(
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
+    enabled: Boolean = true,
     content: @Composable ColumnScope.() -> Unit
 ) {
     val scale = LocalFloatingBottomBarTabScale.current
+    val interactive = enabled && LocalFloatingBottomBarTabInteractive.current
     Column(
         modifier
             .clip(ContinuousCapsule)
-            .clickable(
-                interactionSource = null,
-                indication = null,
-                role = Role.Tab,
-                onClick = onClick,
+            // 背景采样副本与隐藏的导航项不能保留触摸节点,alpha=0 不会取消命中。
+            .then(
+                if (interactive) Modifier.clickable(
+                    interactionSource = null,
+                    indication = null,
+                    role = Role.Tab,
+                    onClick = onClick,
+                ) else Modifier
             )
             .fillMaxHeight()
             .weight(1f)
@@ -114,8 +132,13 @@ fun FloatingBottomBar(
     tabsCount: Int,
     isBlurEnabled: Boolean = true,
     hasCustomIcons: Boolean = false,
+    compactProgress: Float = 0f,
+    expandedWidth: Dp? = null,
+    onRestoreNavigation: () -> Unit = {},
     content: @Composable RowScope.() -> Unit
 ) {
+    val navigationAlpha = (1f - compactProgress * 3f).coerceIn(0f, 1f)
+    val menuAlpha = ((compactProgress - 0.35f) / 0.65f).coerceIn(0f, 1f)
     val isInLightTheme = !LegadoTheme.isDark
     val themeSettings = LocalAppUiConfiguration.current.theme
     val customColors = themeSettings.customColors(LegadoTheme.isDark)
@@ -251,13 +274,9 @@ fun FloatingBottomBar(
         modifier = modifier.width(IntrinsicSize.Min),
         contentAlignment = Alignment.CenterStart
     ) {
-        Row(
+        Box(
             Modifier
-                .onGloballyPositioned { coords ->
-                    totalWidthPx = coords.size.width.toFloat()
-                    val contentWidthPx = totalWidthPx - with(density) { 8.dp.toPx() }
-                    tabWidthPx = contentWidthPx / tabsCount
-                }
+                .fillMaxWidth()
                 .graphicsLayer { translationX = panelOffset }
                 .clickable(
                     interactionSource = remember { MutableInteractionSource() },
@@ -303,10 +322,30 @@ fun FloatingBottomBar(
                     }
                 )
                 .height(64.dp)
-                .padding(4.dp),
-            verticalAlignment = Alignment.CenterVertically,
-            content = content
-        )
+                .then(if (compactProgress > 0f) Modifier.clip(ContinuousCapsule) else Modifier),
+        ) {
+            Row(
+                Modifier
+                    .then(
+                        if (expandedWidth != null) Modifier
+                            .wrapContentWidth(
+                                Alignment.Start,
+                                unbounded = true
+                            )
+                            .requiredWidth(expandedWidth) else Modifier
+                    )
+                    .height(64.dp)
+                    .onGloballyPositioned { coords ->
+                        totalWidthPx = coords.size.width.toFloat()
+                        tabWidthPx = (totalWidthPx - with(density) { 8.dp.toPx() }) / tabsCount
+                    }
+                    .graphicsLayer { alpha = navigationAlpha }
+                    .then(if (compactProgress > 0f) Modifier.clearAndSetSemantics {} else Modifier)
+                    .padding(4.dp),
+                verticalAlignment = Alignment.CenterVertically,
+                content = content,
+            )
+        }
 
         CompositionLocalProvider(
             LocalFloatingBottomBarTabScale provides {
@@ -315,10 +354,19 @@ fun FloatingBottomBar(
                 } else {
                     1f
                 }
-            }
+            },
+            LocalFloatingBottomBarTabInteractive provides false,
         ) {
             Row(
                 Modifier
+                    .then(
+                        if (expandedWidth != null) Modifier
+                            .wrapContentWidth(
+                                Alignment.Start,
+                                unbounded = true
+                            )
+                            .requiredWidth(expandedWidth) else Modifier
+                    )
                     .clearAndSetSemantics {}
                     .alpha(0f)
                     .layerBackdrop(tabsBackdrop)
@@ -369,79 +417,103 @@ fun FloatingBottomBar(
         if (tabWidthPx > 0f) {
             Box(
                 Modifier
-                    .padding(horizontal = 4.dp)
-                    .graphicsLayer {
-                        val contentWidth = totalWidthPx - with(density) { 8.dp.toPx() }
-                        val singleTabWidth = contentWidth / tabsCount
-                        val progressOffset = dampedDragAnimation.value * singleTabWidth
+                    .matchParentSize()
+                    .then(if (compactProgress > 0f) Modifier.clip(ContinuousCapsule) else Modifier)
+            ) {
+                Box(
+                    Modifier
+                        .align(Alignment.CenterStart)
+                        .padding(horizontal = 4.dp)
+                        .graphicsLayer {
+                            alpha = navigationAlpha
+                            val contentWidth = totalWidthPx - with(density) { 8.dp.toPx() }
+                            val singleTabWidth = contentWidth / tabsCount
+                            val progressOffset = dampedDragAnimation.value * singleTabWidth
 
-                        translationX = if (isLtr) {
-                            progressOffset + panelOffset
-                        } else {
-                            -progressOffset + panelOffset
-                        }
-                    }
-                    .then(
-                        if (isBlurEnabled && interactiveHighlight != null) {
-                            interactiveHighlight.gestureModifier
-                        } else {
-                            Modifier
+                            translationX = if (isLtr) {
+                                progressOffset + panelOffset
+                            } else {
+                                -progressOffset + panelOffset
+                            }
                         }
-                    )
-                    .then(dampedDragAnimation.modifier)
-                    .drawBackdrop(
-                        backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
-                        shape = { ContinuousCapsule },
-                        effects = {
-                            if (isBlurEnabled) {
-                                val progress = dampedDragAnimation.pressProgress
-                                lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, true)
+                        .then(
+                            if (compactProgress <= 0f && isBlurEnabled && interactiveHighlight != null) {
+                                interactiveHighlight.gestureModifier
+                            } else {
+                                Modifier
                             }
-                        },
-                        highlight = {
-                            Highlight.Default.copy(
-                                alpha = if (isBlurEnabled) {
-                                    dampedDragAnimation.pressProgress
-                                } else {
-                                    0f
+                        )
+                        .then(if (compactProgress <= 0f) dampedDragAnimation.modifier else Modifier)
+                        .drawBackdrop(
+                            backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
+                            shape = { ContinuousCapsule },
+                            effects = {
+                                if (isBlurEnabled) {
+                                    val progress = dampedDragAnimation.pressProgress
+                                    lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, true)
                                 }
-                            )
-                        },
-                        shadow = {
-                            Shadow(alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f)
-                        },
-                        innerShadow = {
-                            InnerShadow(
-                                radius = 8f.dp * dampedDragAnimation.pressProgress,
-                                alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
-                            )
-                        },
-                        layerBlock = {
-                            if (isBlurEnabled) {
-                                scaleX = dampedDragAnimation.scaleX
-                                scaleY = dampedDragAnimation.scaleY
-                                val velocity = dampedDragAnimation.velocity / 10f
-                                scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
-                                scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+                            },
+                            highlight = {
+                                Highlight.Default.copy(
+                                    alpha = if (isBlurEnabled) {
+                                        dampedDragAnimation.pressProgress
+                                    } else {
+                                        0f
+                                    }
+                                )
+                            },
+                            shadow = {
+                                Shadow(alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f)
+                            },
+                            innerShadow = {
+                                InnerShadow(
+                                    radius = 8f.dp * dampedDragAnimation.pressProgress,
+                                    alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
+                                )
+                            },
+                            layerBlock = {
+                                if (isBlurEnabled) {
+                                    scaleX = dampedDragAnimation.scaleX
+                                    scaleY = dampedDragAnimation.scaleY
+                                    val velocity = dampedDragAnimation.velocity / 10f
+                                    scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
+                                    scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+                                }
+                            },
+                            onDrawSurface = {
+                                val progress =
+                                    if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
+                                drawRect(
+                                    color = if (isInLightTheme) {
+                                        Color.Black.copy(0.1f)
+                                    } else {
+                                        Color.White.copy(0.1f)
+                                    },
+                                    alpha = 1f - progress
+                                )
+                                drawRect(Color.Black.copy(alpha = 0.03f * progress))
                             }
-                        },
-                        onDrawSurface = {
-                            val progress =
-                                if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
-                            drawRect(
-                                color = if (isInLightTheme) {
-                                    Color.Black.copy(0.1f)
-                                } else {
-                                    Color.White.copy(0.1f)
-                                },
-                                alpha = 1f - progress
-                            )
-                            drawRect(Color.Black.copy(alpha = 0.03f * progress))
-                        }
-                    )
-                    .height(56.dp)
-                    .width(with(density) { ((totalWidthPx - 8.dp.toPx()) / tabsCount).toDp() })
-            )
+                        )
+                        .height(56.dp)
+                        .width(with(density) { ((totalWidthPx - 8.dp.toPx()) / tabsCount).toDp() })
+                )
+            }
+        }
+        // 菜单位于根容器最上层,背景采样与指示器不参与它的触摸命中。
+        if (compactProgress > 0f) {
+            IconButton(
+                onClick = onRestoreNavigation,
+                enabled = compactProgress > 0.35f,
+                modifier = Modifier
+                    .align(Alignment.Center)
+                    .size(64.dp)
+                    .graphicsLayer { alpha = menuAlpha },
+            ) {
+                Icon(
+                    Icons.Default.Menu, stringResource(R.string.home_capsule_restore_navigation),
+                    tint = LegadoTheme.colorScheme.onSurface
+                )
+            }
         }
     }
 }

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt

@@ -132,7 +132,8 @@ fun AppModalBottomSheet(
             )
             val density = LocalDensity.current
             val maxHeight = with(density) {
-                LocalWindowInfo.current.containerSize.height.toDp() * 0.8f
+                val containerHeight = LocalWindowInfo.current.containerSize.height.toDp()
+                containerHeight * 0.8f
             }
 
             MaterialExpressiveTheme(

+ 8 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -2945,7 +2945,7 @@
     <string name="clear_voice_binding">清除声音绑定</string>
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">显示朗读胶囊</string>
-    <string name="show_read_aloud_capsule_summary">朗读时在阅读页显示封面、暂停和关闭按钮</string>
+    <string name="show_read_aloud_capsule_summary">在应用内显示朗读控制,授权悬浮窗后可在其他应用上使用。</string>
     <string name="read_aloud_keep_on_exit">退出阅读时继续朗读</string>
     <string name="read_aloud_keep_on_exit_summary">关闭阅读界面后不停朗读,继续在后台播放</string>
     <string name="reset_read_aloud_capsule_position">重置朗读胶囊位置</string>
@@ -3418,4 +3418,11 @@
     <string name="bookshelf_conflict_migrate_done">已迁移到新的书源</string>
     <string name="bookshelf_conflict_resolve_failed">处理失败,请重试</string>
     <string name="bookshelf_conflict_toc_failed">获取目录失败,已取消迁移,请重试</string>
+    <string name="read_aloud_overlay_permission">允许胶囊显示在其他应用上层</string>
+    <string name="read_aloud_overlay_granted">已授权。朗读运行时可在桌面和其他应用上使用胶囊。</string>
+    <string name="read_aloud_overlay_required">授权后可在桌面和其他应用上使用胶囊,未授权不影响应用内胶囊。</string>
+    <string name="read_aloud_overlay_unavailable">此设备无法打开悬浮窗权限设置。</string>
+    <string name="home_capsule_restore_navigation">恢复导航栏</string>
+    <string name="home_capsule_expand_controls">展开播放控制</string>
+    <string name="home_capsule_chapter">第 %d 章</string>
 </resources>

+ 8 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -2677,7 +2677,7 @@
     <string name="clear_voice_binding">清除聲音綁定</string>
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">顯示朗讀膠囊</string>
-    <string name="show_read_aloud_capsule_summary">朗讀時在閱讀頁顯示封面、暫停和關閉按鈕</string>
+    <string name="show_read_aloud_capsule_summary">在應用內顯示朗讀控制,授權懸浮視窗後可在其他應用程式上使用。</string>
     <string name="read_aloud_keep_on_exit">離開閱讀時繼續朗讀</string>
     <string name="read_aloud_keep_on_exit_summary">關閉閱讀介面後不停朗讀,繼續在背景播放</string>
     <string name="reset_read_aloud_capsule_position">重設朗讀膠囊位置</string>
@@ -2977,4 +2977,11 @@
     <string name="feature_bookmarks_no_matching_notes">找不到相關筆記</string>
     <string name="feature_bookmarks_no_notes">暫無筆記</string>
     <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
+    <string name="read_aloud_overlay_permission">允許膠囊顯示在其他應用程式上層</string>
+    <string name="read_aloud_overlay_granted">已授權。朗讀運行時可在桌面和其他應用程式上使用膠囊。</string>
+    <string name="read_aloud_overlay_required">授權後可在桌面和其他應用程式上使用膠囊,未授權不影響應用內膠囊。</string>
+    <string name="read_aloud_overlay_unavailable">此裝置無法開啟懸浮視窗權限設定。</string>
+    <string name="home_capsule_restore_navigation">恢復導覽列</string>
+    <string name="home_capsule_expand_controls">展開播放控制</string>
+    <string name="home_capsule_chapter">第 %d 章</string>
 </resources>

+ 8 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -2682,7 +2682,7 @@
     <string name="clear_voice_binding">清除聲音綁定</string>
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">顯示朗讀膠囊</string>
-    <string name="show_read_aloud_capsule_summary">朗讀時在閱讀頁顯示封面、暫停和關閉按鈕</string>
+    <string name="show_read_aloud_capsule_summary">在應用內顯示朗讀控制,授權懸浮視窗後可在其他應用程式上使用。</string>
     <string name="read_aloud_keep_on_exit">離開閱讀時繼續朗讀</string>
     <string name="read_aloud_keep_on_exit_summary">關閉閱讀介面後不停朗讀,繼續在背景播放</string>
     <string name="reset_read_aloud_capsule_position">重設朗讀膠囊位置</string>
@@ -2982,4 +2982,11 @@
     <string name="feature_bookmarks_no_matching_notes">找不到相關筆記</string>
     <string name="feature_bookmarks_no_notes">暫無筆記</string>
     <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
+    <string name="read_aloud_overlay_permission">允許膠囊顯示在其他應用程式上層</string>
+    <string name="read_aloud_overlay_granted">已授權。朗讀運行時可在桌面和其他應用程式上使用膠囊。</string>
+    <string name="read_aloud_overlay_required">授權後可在桌面和其他應用程式上使用膠囊,未授權不影響應用內膠囊。</string>
+    <string name="read_aloud_overlay_unavailable">此裝置無法開啟懸浮視窗權限設定。</string>
+    <string name="home_capsule_restore_navigation">恢復導覽列</string>
+    <string name="home_capsule_expand_controls">展開播放控制</string>
+    <string name="home_capsule_chapter">第 %d 章</string>
 </resources>

+ 8 - 1
app/src/main/res/values/strings.xml

@@ -2949,7 +2949,7 @@
     <string name="clear_voice_binding">Clear voice assignment</string>
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">Show read-aloud capsule</string>
-    <string name="show_read_aloud_capsule_summary">Show cover, pause and stop controls over the reading page</string>
+    <string name="show_read_aloud_capsule_summary">Show read-aloud controls across the app. Grant overlay permission to use them in other apps.</string>
     <string name="read_aloud_keep_on_exit">Keep reading aloud after exit</string>
     <string name="read_aloud_keep_on_exit_summary">Keep reading aloud in the background after leaving the reader</string>
     <string name="reset_read_aloud_capsule_position">Reset capsule position</string>
@@ -3422,4 +3422,11 @@
     <string name="bookshelf_conflict_migrate_done">Migrated to the new source</string>
     <string name="bookshelf_conflict_resolve_failed">Could not finish the operation</string>
     <string name="bookshelf_conflict_toc_failed">Could not load the table of contents. Migration cancelled, please retry.</string>
+    <string name="read_aloud_overlay_permission">Display capsule over other apps</string>
+    <string name="read_aloud_overlay_granted">Allowed. The capsule appears outside this app while read aloud is running.</string>
+    <string name="read_aloud_overlay_required">Grant permission to use the capsule on the home screen and in other apps. The in-app capsule works without it.</string>
+    <string name="read_aloud_overlay_unavailable">This device cannot open overlay permission settings.</string>
+    <string name="home_capsule_restore_navigation">Restore navigation</string>
+    <string name="home_capsule_expand_controls">Show playback controls</string>
+    <string name="home_capsule_chapter">Chapter %d</string>
 </resources>

+ 117 - 0
app/src/test/java/io/legado/app/core/ui/player/PlayerMorphAnchorsTest.kt

@@ -0,0 +1,117 @@
+package io.legado.app.core.ui.player
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.runtime.MonotonicFrameClock
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.unit.Density
+import io.legado.app.ui.book.readaloud.morph.CapsuleAnchorKind
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+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.Test
+
+class PlayerMorphAnchorsTest {
+    private val panel = Rect(300f, 800f, 500f, 864f)
+    private val cover = Rect(304f, 804f, 360f, 860f)
+    private fun state() = ReadAloudMorphState(Animatable(0f), Density(1f)).apply {
+        reportScreenBounds(Rect(0f, 0f, 1080f, 1920f))
+        reportCoverEnd(Rect(200f, 300f, 700f, 800f), 8f)
+    }
+
+    private fun ReadAloudMorphState.anchors(kind: CapsuleAnchorKind = CapsuleAnchorKind.HomeBar) {
+        reportPanelStart(panel, 32f, anchorKind = kind)
+        reportCoverStart(cover, 28f, anchorKind = kind)
+    }
+
+    private class Frames(val beforeFrame: (Int) -> Unit = {}) : MonotonicFrameClock {
+        var count = 0
+        override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R {
+            beforeFrame(++count)
+            return onFrame(count * 16_666_667L)
+        }
+    }
+
+    @Test
+    fun missingCapsuleStopsWaitingAndCanFadeInWithoutCoordinates() = runTest {
+        val morph = state()
+        val clock = Frames()
+        assertFalse(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(PLAYER_ANCHOR_WAIT_FRAMES, clock.count)
+        morph.useFadeOnlyOpening()
+        morph.anchors() // 迟到的布局不能改变本次展开。
+        morph.progress.snapTo(0.5f)
+        morph.anchors()
+        assertFalse(morph.hasCapsuleAnchors)
+        assertNull(morph.panelFrame())
+        assertNull(morph.coverFrame())
+        withContext(clock) { morph.animateTo(1f) }
+        morph.anchors() // 完全展开后接收真实返回位置。
+        assertTrue(morph.hasCapsuleAnchors)
+        morph.progress.snapTo(0.5f)
+        assertNotNull(morph.panelFrame())
+    }
+
+    @Test
+    fun realAnchorMustStayStableForTwoFramesBeforeOpening() = runTest {
+        val morph = state()
+        val clock = Frames { if (it == 4) morph.anchors() }
+        assertTrue(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(6, clock.count)
+    }
+
+    @Test
+    fun staleGlobalAnchorCannotStartAHomeCapsuleTransition() = runTest {
+        val morph = state().apply {
+            anchors(CapsuleAnchorKind.Global)
+            expectCapsuleAnchors(CapsuleAnchorKind.HomeBar)
+        }
+        val clock = Frames()
+        assertFalse(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(PLAYER_ANCHOR_WAIT_FRAMES, clock.count)
+    }
+
+    @Test
+    fun disabledCapsuleDoesNotWaitForAnAnchor() = runTest {
+        val clock = Frames()
+        // 即使播放页目标也尚未布局,关闭胶囊仍直接淡入。
+        val morph = ReadAloudMorphState(Animatable(0f), Density(1f))
+        assertFalse(withContext(clock) { awaitPlayerMorphAnchors(morph, false) })
+        assertEquals(0, clock.count)
+    }
+
+    @Test
+    fun missingPlayerLayoutAlsoHasABoundedWait() = runTest {
+        val morph = ReadAloudMorphState(Animatable(0f), Density(1f))
+        val clock = Frames()
+        assertFalse(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(PLAYER_ANCHOR_WAIT_FRAMES, clock.count)
+    }
+
+    @Test
+    fun unlinkedCoverDoesNotWaitForAFlightEndpoint() = runTest {
+        val morph = ReadAloudMorphState(Animatable(0f), Density(1f)).apply {
+            reportScreenBounds(Rect(0f, 0f, 1080f, 1920f))
+            reportCapsuleCoverLinked(false)
+            anchors()
+        }
+        val clock = Frames()
+        assertTrue(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(3, clock.count)
+    }
+
+    @Test
+    fun linkedCoverStillWaitsForTheRealFlightEndpoint() = runTest {
+        val morph = ReadAloudMorphState(Animatable(0f), Density(1f)).apply {
+            reportScreenBounds(Rect(0f, 0f, 1080f, 1920f))
+            anchors()
+        }
+        val clock = Frames { if (it == 4) morph.reportCoverEnd(Rect(200f, 300f, 700f, 800f), 8f) }
+        assertTrue(withContext(clock) { awaitPlayerMorphAnchors(morph, true) })
+        assertEquals(5, clock.count)
+    }
+}

+ 190 - 0
app/src/test/java/io/legado/app/core/ui/player/PlayerMorphHostInteractionTest.kt

@@ -0,0 +1,190 @@
+package io.legado.app.core.ui.player
+
+import android.app.Application
+import android.os.Looper
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.BackEventCompat
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.animation.core.Animatable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.ViewRootForTest
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getAllSemanticsNodes
+import androidx.compose.ui.unit.Density
+import io.legado.app.data.repository.CoverAlbumRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.usecase.CoverAlbumUseCase
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.ui.book.readaloud.morph.ReadAloudMorphState
+import io.legado.app.ui.main.shouldHandleActivityBack
+import io.legado.app.ui.theme.AppTheme
+import io.legado.app.ui.widget.components.text.AppText
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+// API 34 的独立 sandbox 避免与胶囊触摸测试共用已被重置的 Choreographer。
+@Config(application = Application::class, sdk = [34])
+class PlayerMorphHostInteractionTest {
+    @Test
+    fun backClosesTheModalPlayerAndRestoresUnderlaySemanticsWithEitherRegistrationOrder() {
+        // 在同一个 Android 主循环里覆盖两种注册顺序与返回开关,避免 Robolectric
+        // 重置 Choreographer 后复用 Compose 的全局主线程调度器。
+        verifyBack(false, true)
+        verifyBack(false, false)
+        verifyBack(true, true)
+        verifyBack(false, true, initialProgress = 0f)
+    }
+
+    private fun verifyBack(
+        predictive: Boolean,
+        activityRegistersLast: Boolean,
+        initialProgress: Float = 1f
+    ) {
+        val application = RuntimeEnvironment.getApplication()
+        AppConfigStore.init(application)
+        stopKoin()
+        startKoin {
+            modules(module {
+                single {
+                    CoverAlbumUseCase(
+                        CoverAlbumRepository(
+                            application,
+                            SettingsRepository()
+                        )
+                    )
+                }
+            })
+        }
+        val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible()
+        val activity = controller.get()
+        var visible by mutableStateOf(true)
+        val morph = ReadAloudMorphState(Animatable(initialProgress), Density(1f))
+        var routeBacks = 0
+        var dismissals = 0
+        try {
+            activity.setContent {
+                CompositionLocalProvider(LocalInspectionMode provides true) {
+                    AppTheme(AppUiConfiguration(), applyBackground = false) {
+                        val present by remember { derivedStateOf { visible || morph.progress.value > 0f } }
+                        val fallback: @Composable () -> Unit = {
+                            BackHandler(
+                                shouldHandleActivityBack(
+                                    predictive,
+                                    present
+                                )
+                            ) { routeBacks++ }
+                        }
+                        Box(Modifier.fillMaxSize()) {
+                            Box(
+                                Modifier
+                                    .fillMaxSize()
+                                    .playerUnderlaySemantics(present)
+                            ) { AppText("Bookshelf") }
+                            if (!activityRegistersLast) fallback()
+                            PlayerMorphHost(
+                                appearance = PlayerMorphAppearance("Book", "", null, null, 0),
+                                playerTheme = null, morph = morph, visible = visible,
+                                awaitCapsuleAnchor = true,
+                                predictiveBackEnabled = predictive,
+                                onDismiss = { dismissals++; visible = false },
+                            ) { AppText("Player controls") }
+                            if (activityRegistersLast) fallback()
+                        }
+                    }
+                }
+            }
+            fun frames(count: Int) {
+                repeat(count) {
+                    shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+                    val decor = activity.window.decorView
+                    decor.measure(
+                        View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+                        View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY)
+                    )
+                    decor.layout(0, 0, 1080, 1920)
+                }
+            }
+            frames(if (initialProgress == 0f) 180 else 5)
+            assertEquals(1f, morph.progress.value)
+            assertFalse(visibleTexts(activity.window.decorView).contains("Bookshelf"))
+            assertTrue(visibleTexts(activity.window.decorView).contains("Player controls"))
+            activity.onBackPressedDispatcher.dispatchOnBackStarted(
+                BackEventCompat(
+                    0f,
+                    0f,
+                    0f,
+                    BackEventCompat.EDGE_LEFT
+                )
+            )
+            activity.onBackPressedDispatcher.dispatchOnBackProgressed(
+                BackEventCompat(
+                    0f,
+                    0f,
+                    0.6f,
+                    BackEventCompat.EDGE_LEFT
+                )
+            )
+            frames(5)
+            if (predictive) assertTrue(morph.progress.value < 1f) else assertEquals(
+                1f,
+                morph.progress.value
+            )
+            activity.onBackPressedDispatcher.dispatchOnBackCancelled()
+            frames(90)
+            assertEquals(1f, morph.progress.value)
+            assertEquals(0, dismissals)
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames(90)
+            assertEquals("The covered route must not receive this back", 0, routeBacks)
+            assertEquals(
+                "progress=${morph.progress.value} running=${morph.progress.isRunning} visible=$visible",
+                1,
+                dismissals
+            )
+            assertFalse(visible)
+            assertEquals(0f, morph.progress.value)
+            assertTrue(visibleTexts(activity.window.decorView).contains("Bookshelf"))
+            assertFalse(visibleTexts(activity.window.decorView).contains("Player controls"))
+            activity.onBackPressedDispatcher.onBackPressed()
+            if (!predictive) assertEquals(1, routeBacks)
+        } finally {
+            controller.pause().stop().destroy()
+            stopKoin()
+        }
+    }
+
+    private fun visibleTexts(view: View): List<String> {
+        if (view is ViewRootForTest) return view.semanticsOwner.getAllSemanticsNodes(mergingEnabled = true)
+            .flatMap {
+                it.config.getOrElse(SemanticsProperties.Text) { emptyList() }
+                    .map { text -> text.text }
+            }
+        return if (view is ViewGroup) (0 until view.childCount).flatMap {
+            visibleTexts(
+                view.getChildAt(
+                    it
+                )
+            )
+        }
+        else emptyList()
+    }
+}

+ 2 - 0
app/src/test/java/io/legado/app/model/ReadAloudSessionStoreTest.kt

@@ -17,6 +17,8 @@ class ReadAloudSessionStoreTest {
             chapterPosition = 42,
             chapterLength = 100,
             text = "current paragraph",
+            chapterTitle = "正在朗读的章节",
+            chapterIndex = 3,
         )
 
         store.setStatus(ReadAloudSessionStatus.Playing)

+ 134 - 0
app/src/test/java/io/legado/app/service/readaloud/PlaybackCapsuleGatewayAdapterTest.kt

@@ -0,0 +1,134 @@
+package io.legado.app.service.readaloud
+
+import android.app.Application
+import io.legado.app.constant.Status
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.model.AudioPlay
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.service.AudioPlayService
+import io.legado.app.service.BaseReadAloudService
+import io.legado.app.service.playback.PlaybackCapsuleGatewayAdapter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class PlaybackCapsuleGatewayAdapterTest {
+    private val dispatcher = StandardTestDispatcher()
+    private var previousBook: Book? = null
+    private var previousChapter: BookChapter? = null
+    private var previousChapterIndex = 0
+    private var previousStatus = Status.STOP
+
+    @Before
+    fun setUp() {
+        Dispatchers.setMain(dispatcher)
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        previousChapter = AudioPlay.durChapter
+        previousChapterIndex = AudioPlay.durChapterIndex
+        previousBook = AudioPlay.book
+        previousStatus = AudioPlay.status
+        assertTrue(!AudioPlayService.isRun)
+        assertTrue(!BaseReadAloudService.isRun)
+        AudioPlay.book = Book(bookUrl = "audio-a", name = "有声书")
+        AudioPlay.status = Status.STOP
+    }
+
+    @After
+    fun tearDown() {
+        AudioPlay.durChapter = previousChapter
+        AudioPlay.durChapterIndex = previousChapterIndex
+        AudioPlay.book = previousBook
+        AudioPlay.status = previousStatus
+        Dispatchers.resetMain()
+    }
+
+    private fun gateway() = PlaybackCapsuleGatewayAdapter(
+        RuntimeEnvironment.getApplication(), ReadAloudSessionStore(),
+    )
+
+    @Test
+    fun loadedBookWithoutServiceRemainsAResumablePausedCapsule() = runTest(dispatcher) {
+        val gateway = gateway()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { gateway.state.collect { } }
+        runCurrent()
+        assertNull(gateway.state.value.source)
+        // 旧模型可能残留 PLAY;没有服务时不能显示为播放中。
+        AudioPlay.status = Status.PLAY
+        gateway.prepareAudioBook("audio-a")
+        runCurrent()
+        assertEquals(PlaybackCapsuleSource.AudioBook, gateway.state.value.source)
+        assertEquals("audio-a", gateway.state.value.bookUrl)
+        assertTrue(gateway.state.value.isPaused)
+        gateway.stop(PlaybackCapsuleSource.AudioBook)
+        runCurrent()
+        assertNull(gateway.state.value.source)
+    }
+
+    @Test
+    fun stoppingOldServiceDoesNotClearNewPreparedBook() = runTest(dispatcher) {
+        val gateway = gateway()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { gateway.state.collect { } }
+        gateway.setSessionAvailable(PlaybackCapsuleSource.AudioBook, true)
+        gateway.prepareAudioBook("audio-a")
+        runCurrent()
+        AudioPlay.book = Book(bookUrl = "audio-b", name = "第二本")
+        gateway.prepareAudioBook("audio-b")
+        // resetData 停止旧服务的回调可能晚于新书加载。
+        gateway.setSessionAvailable(PlaybackCapsuleSource.AudioBook, false)
+        runCurrent()
+        assertEquals("audio-b", gateway.state.value.bookUrl)
+        assertEquals(PlaybackCapsuleSource.AudioBook, gateway.state.value.source)
+        assertTrue(gateway.state.value.isPaused)
+    }
+
+    @Test
+    fun chapterMetadataFollowsTheLoadedAudioBook() = runTest(dispatcher) {
+        AudioPlay.durChapterIndex = 2
+        AudioPlay.durChapter = BookChapter(bookUrl = "audio-a", title = "第三章", index = 2)
+        val gateway = gateway()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { gateway.state.collect { } }
+        gateway.prepareAudioBook("audio-a")
+        runCurrent()
+        assertEquals("第三章", gateway.state.value.chapterTitle)
+        assertEquals(2, gateway.state.value.chapterIndex)
+        AudioPlay.book = Book(bookUrl = "audio-b")
+        gateway.prepareAudioBook("audio-b")
+        runCurrent()
+        // 新书目录尚未加载时不能显示上一书的章节标题。
+        assertEquals("", gateway.state.value.chapterTitle)
+    }
+
+    @Test
+    fun destroyingItsOwnServiceClearsPreparedSession() = runTest(dispatcher) {
+        val gateway = gateway()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { gateway.state.collect { } }
+        gateway.prepareAudioBook("audio-a")
+        gateway.setSessionAvailable(PlaybackCapsuleSource.AudioBook, true)
+        runCurrent()
+        gateway.setSessionAvailable(PlaybackCapsuleSource.AudioBook, false)
+        runCurrent()
+        assertNull(gateway.state.value.source)
+    }
+}

+ 73 - 0
app/src/test/java/io/legado/app/service/readaloud/PlaybackCapsuleSelectionTest.kt

@@ -0,0 +1,73 @@
+package io.legado.app.service.readaloud
+
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import io.legado.app.domain.model.selectPlaybackCapsule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PlaybackCapsuleSelectionTest {
+    private val tts = PlaybackCapsuleState(
+        source = PlaybackCapsuleSource.ReadAloud,
+        bookUrl = "tts",
+        isPaused = false
+    )
+    private val audio = PlaybackCapsuleState(
+        source = PlaybackCapsuleSource.AudioBook,
+        bookUrl = "audio",
+        isPaused = false
+    )
+
+    @Test
+    fun `audio starts while tts is paused then remains selected on pause`() {
+        val selected = selectPlaybackCapsule(tts.copy(isPaused = true), audio, tts.source)
+        assertEquals(audio, selected)
+        assertEquals(
+            audio.copy(isPaused = true), selectPlaybackCapsule(
+                tts.copy(isPaused = true), audio.copy(isPaused = true), selected.source
+            )
+        )
+    }
+
+    @Test
+    fun `resuming tts takes over a paused audio capsule`() {
+        assertEquals(tts, selectPlaybackCapsule(tts, audio.copy(isPaused = true), audio.source))
+    }
+
+    @Test
+    fun `stopping selected audio falls back to surviving paused tts`() {
+        assertEquals(
+            tts.copy(isPaused = true), selectPlaybackCapsule(
+                tts.copy(isPaused = true), PlaybackCapsuleState(), audio.source
+            )
+        )
+    }
+
+    @Test
+    fun `no live service means no capsule even with old book metadata`() {
+        assertEquals(
+            PlaybackCapsuleState(), selectPlaybackCapsule(
+                PlaybackCapsuleState(bookUrl = "old-tts"),
+                PlaybackCapsuleState(bookUrl = "old-audio"),
+                audio.source
+            )
+        )
+    }
+
+    @Test
+    fun `simultaneous playback keeps current source rather than flickering`() {
+        assertEquals(audio, selectPlaybackCapsule(tts, audio, audio.source))
+        assertEquals(tts, selectPlaybackCapsule(tts, audio, tts.source))
+    }
+
+    @Test
+    fun `only selected engine can own the external window`() {
+        assertTrue(shouldShowExternalCapsule(true, false, true, audio.source, audio.source))
+        assertFalse(shouldShowExternalCapsule(true, false, true, tts.source, audio.source))
+        assertFalse(shouldShowExternalCapsule(true, false, true, audio.source, null))
+        assertFalse(shouldShowExternalCapsule(true, true, true, audio.source, audio.source))
+        assertFalse(shouldShowExternalCapsule(true, false, false, audio.source, audio.source))
+    }
+}

+ 92 - 0
app/src/test/java/io/legado/app/service/readaloud/ReadAloudOverlayWindowTest.kt

@@ -0,0 +1,92 @@
+package io.legado.app.service.readaloud
+
+import io.legado.app.feature.readaloud.overlay.capsuleDockSide
+import io.legado.app.feature.readaloud.overlay.capsuleOffsetY
+import io.legado.app.feature.readaloud.overlay.capsulePresentationOffsetX
+import io.legado.app.feature.readaloud.overlay.capsuleVisibleOffsetX
+import io.legado.app.feature.readaloud.overlay.capsuleWindowY
+import io.legado.app.feature.readaloud.overlay.snapCapsuleOffsetX
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReadAloudOverlayWindowTest {
+    @Test
+    fun `capsule keeps the same screen center across host changes and collapse`() {
+        val density = 3f
+        val saved = -120f
+        val expandedHeight = 240
+        val collapsedHeight = 144
+        val expandedY = capsuleWindowY(saved, density, expandedHeight)
+        val collapsedY = capsuleWindowY(saved, density, collapsedHeight)
+        assertEquals(expandedY + expandedHeight / 2, collapsedY + collapsedHeight / 2)
+        assertEquals(saved, capsuleOffsetY(expandedY, density, expandedHeight), 0.001f)
+        assertEquals(saved, capsuleOffsetY(collapsedY, density, collapsedHeight), 0.001f)
+    }
+
+    @Test
+    fun `position survives manual drag round trip at different densities`() {
+        for (density in listOf(1f, 2.75f, 3f)) {
+            val draggedWindowY = 512
+            val saved = capsuleOffsetY(draggedWindowY, density, 240)
+            assertEquals(draggedWindowY, capsuleWindowY(saved, density, 240))
+        }
+    }
+
+    @Test
+    fun `permission denial and visible app never create an external duplicate`() {
+        assertTrue(shouldShowExternalCapsule(true, false, true))
+        assertFalse(shouldShowExternalCapsule(true, false, false))
+        assertFalse(shouldShowExternalCapsule(true, true, true))
+        assertFalse(shouldShowExternalCapsule(false, false, true))
+    }
+
+    @Test
+    fun `only release near an edge snaps while center positions stay unchanged`() {
+        assertEquals(-180f, snapCapsuleOffsetX(-130f, 360f), 0.001f)
+        assertEquals(180f, snapCapsuleOffsetX(130f, 360f), 0.001f)
+        assertEquals(24f, snapCapsuleOffsetX(24f, 360f), 0.001f)
+        assertEquals(180f, snapCapsuleOffsetX(1000f, 360f), 0.001f)
+        assertEquals(-1, capsuleDockSide(-180f, 360f))
+        assertEquals(1, capsuleDockSide(180f, 360f))
+        assertEquals(0, capsuleDockSide(24f, 360f))
+    }
+
+    @Test
+    fun `full mini cover extends halfway outside the screen while expanded controls fit`() {
+        val mini = capsulePresentationOffsetX(180f, 360f, 80f, 0f)
+        val expanded = capsulePresentationOffsetX(180f, 360f, 168f, 1f)
+        assertEquals(180f, mini, 0.001f)
+        assertEquals(96f, expanded, 0.001f)
+        // 始终是 48dp 完整封面,右半 24dp 自然落在物理屏幕之外。
+        assertEquals(384f, 180f + mini + 24f, 0.001f)
+        assertEquals(336f, 180f + mini - 24f, 0.001f)
+        assertEquals(-180f, capsulePresentationOffsetX(-180f, 360f, 80f, 0f), 0.001f)
+        assertEquals(0f, capsuleVisibleOffsetX(180f, 100f, 168f), 0.001f)
+    }
+
+    @Test
+    fun `collapse and expansion share a continuous position with no final half width jump`() {
+        var previous = Float.POSITIVE_INFINITY
+        for (step in 0..100) {
+            val expansion = step / 100f
+            val width = 80f + 88f * expansion
+            val x = capsulePresentationOffsetX(180f, 360f, width, expansion)
+            assertTrue(x <= previous)
+            previous = x
+            assertEquals(-x, capsulePresentationOffsetX(-180f, 360f, width, expansion), 0.001f)
+            assertEquals(24f, capsulePresentationOffsetX(24f, 360f, width, expansion), 0.001f)
+        }
+        assertEquals(180f, capsulePresentationOffsetX(180f, 360f, 80.0088f, 0.0001f), 0.01f)
+        assertEquals(96f, capsulePresentationOffsetX(180f, 360f, 168f, 1f), 0.001f)
+    }
+
+    @Test
+    fun `snap threshold is inclusive and restoration preserves edge anchor`() {
+        assertEquals(180f, snapCapsuleOffsetX(108f, 360f), 0.001f)
+        assertEquals(107f, snapCapsuleOffsetX(107f, 360f), 0.001f)
+        assertEquals(-180f, snapCapsuleOffsetX(-108f, 360f), 0.001f)
+        assertEquals(180f, snapCapsuleOffsetX(180f, 360f), 0.001f)
+    }
+}

+ 115 - 0
app/src/test/java/io/legado/app/ui/book/audio/AudioPlayViewModelInitializationTest.kt

@@ -0,0 +1,115 @@
+package io.legado.app.ui.book.audio
+
+import android.app.Application
+import androidx.lifecycle.ViewModelStore
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.OtherSettingsRepository
+import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.service.playback.PlaybackCapsuleGatewayAdapter
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.model.AudioPlay
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class AudioPlayViewModelInitializationTest {
+    private val dispatcher = StandardTestDispatcher()
+    private val viewModels = ViewModelStore()
+    private lateinit var database: AppDatabase
+    private var previousLyric: String? = null
+
+    @Before
+    fun setUp() {
+        Dispatchers.setMain(dispatcher)
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        database = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(), AppDatabase::class.java,
+        ).allowMainThreadQueries().build()
+        previousLyric = AudioPlay.durLyric
+    }
+
+    @After
+    fun tearDown() {
+        viewModels.clear()
+        AudioPlay.durLyric = previousLyric
+        database.close()
+        Dispatchers.resetMain()
+    }
+
+    @Test
+    fun openingWithoutLyricsHasEmptyInitialList() {
+        AudioPlay.durLyric = null
+
+        assertTrue(createViewModel().uiState.value.lyricLines.isEmpty())
+    }
+
+    @Test
+    fun openingWithBlankLyricsHasEmptyInitialList() {
+        AudioPlay.durLyric = ""
+
+        assertTrue(createViewModel().uiState.value.lyricLines.isEmpty())
+    }
+
+    @Test
+    fun initialLyricsRemainCachedAcrossUiUpdates() = runTest(dispatcher) {
+        AudioPlay.durLyric = "[00:01.50]第一行\n[00:02]第二行"
+        val viewModel = createViewModel()
+        val initialLines = viewModel.uiState.value.lyricLines
+        assertEquals(
+            listOf(AudioLyricLine(1500, "第一行"), AudioLyricLine(2000, "第二行")),
+            initialLines,
+        )
+
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+            viewModel.uiState.collect { }
+        }
+        runCurrent()
+        viewModel.onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Timer))
+        runCurrent()
+
+        assertEquals(AudioPlaySheet.Timer, viewModel.uiState.value.activeSheet)
+        assertSame(initialLines, viewModel.uiState.value.lyricLines)
+    }
+
+    private fun createViewModel(): AudioPlayViewModel {
+        val application = RuntimeEnvironment.getApplication()
+        val books = BookRepository(database.bookDao, database.bookChapterDao, database)
+        val otherSettings = OtherSettingsRepository()
+        val aloudSettings = ReadAloudSettingsRepository()
+        return AudioPlayViewModel(
+            application,
+            AudioPlayCoordinator(
+                application, books, otherSettings, aloudSettings,
+                PlaybackCapsuleGatewayAdapter(application, ReadAloudSessionStore())
+            ),
+            books,
+            otherSettings,
+            aloudSettings,
+            ReadSettingsRepository(SettingsRepository()),
+        ).also { viewModels.put("audio", it) }
+    }
+}

+ 152 - 0
app/src/test/java/io/legado/app/ui/book/readaloud/ReadAloudBarCapsuleInteractionTest.kt

@@ -0,0 +1,152 @@
+package io.legado.app.ui.book.readaloud
+
+import android.app.Application
+import android.os.Looper
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.repository.CoverAlbumRepository
+import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.usecase.CoverAlbumUseCase
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.ui.theme.AppTheme
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.robolectric.Robolectric
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.RobolectricTestRunner
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadAloudBarCapsuleInteractionTest {
+    @Test
+    fun expandedCoverLongPressRestoresTheDockWithoutOpeningOrControllingPlayback() {
+        val application = RuntimeEnvironment.getApplication()
+        AppConfigStore.init(application)
+        stopKoin()
+        startKoin {
+            modules(module {
+                single {
+                    CoverAlbumUseCase(
+                        CoverAlbumRepository(
+                            application,
+                            SettingsRepository()
+                        )
+                    )
+                }
+            })
+        }
+        val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible()
+        val activity = controller.get()
+        var expanded by mutableStateOf(true)
+        var opens = 0
+        var bounds = Rect.Zero
+        val settings = ReadAloudSettingsRepository()
+        val playback = object : PlaybackCapsuleGateway {
+            override val state = MutableStateFlow(
+                PlaybackCapsuleState(
+                    source = PlaybackCapsuleSource.AudioBook,
+                    bookName = "Book",
+                    chapterTitle = "Chapter",
+                )
+            )
+
+            override fun setSessionAvailable(source: PlaybackCapsuleSource, available: Boolean) =
+                error("unexpected playback action")
+
+            override fun prepareAudioBook(bookUrl: String) = error("unexpected playback action")
+            override fun clearPreparedAudioBook(bookUrl: String) =
+                error("unexpected playback action")
+
+            override fun togglePause(source: PlaybackCapsuleSource) =
+                error("unexpected playback action")
+
+            override fun stop(source: PlaybackCapsuleSource) = error("unexpected playback action")
+        }
+        try {
+            activity.setContent {
+                CompositionLocalProvider(LocalInspectionMode provides true) {
+                    AppTheme(AppUiConfiguration(), applyBackground = false) {
+                        Box(Modifier.fillMaxSize()) {
+                            ReadAloudBarCapsuleSlot(
+                                enabled = true, morph = null,
+                                onOpenPlayer = { opens++ }, playbackGateway = playback,
+                                settingsGateway = settings,
+                                controlsExpanded = expanded, expansion = if (expanded) 1f else 0f,
+                                width = if (expanded) 288.dp else 64.dp, expandedWidth = 288.dp,
+                                onControlsExpandedChange = { expanded = it },
+                                modifier = Modifier.onGloballyPositioned {
+                                    bounds = it.boundsInWindow()
+                                },
+                            )
+                        }
+                    }
+                }
+            }
+            val decor = activity.window.decorView
+            repeat(5) {
+                shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+                decor.measure(
+                    View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+                    View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY)
+                )
+                decor.layout(0, 0, 1080, 1920)
+            }
+            assertFalse(bounds.isEmpty)
+            val downTime = SystemClock.uptimeMillis()
+            // 64dp 封面区的中心,远离右侧两个控制按钮。
+            val x = bounds.left + bounds.height / 2f
+            val y = bounds.center.y
+            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0).also {
+                activity.dispatchTouchEvent(it)
+                it.recycle()
+            }
+            // Deliver the down event first; Compose timeout and Robolectric looper use
+            // different clocks, so let both reach the long-press deadline.
+            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+            val holdMillis = ViewConfiguration.getLongPressTimeout().toLong() + 200
+            Thread.sleep(holdMillis)
+            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(holdMillis))
+            MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0)
+                .also {
+                    activity.dispatchTouchEvent(it)
+                    it.recycle()
+                }
+            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+            assertFalse("bounds=$bounds opens=$opens", expanded)
+            assertEquals(0, opens)
+        } finally {
+            controller.pause().stop().destroy()
+            stopKoin()
+        }
+    }
+}

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

@@ -0,0 +1,408 @@
+package io.legado.app.ui.book.readaloud.morph
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.unit.Density
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * 形变几何:两端对齐、双曲线错开、遮罩延后。
+ *
+ * 这些数字直接决定观感,改动必须显式可见。
+ */
+class PlayerMorphFramesTest {
+
+    private val start = Rect(left = 600f, top = 1800f, right = 1080f, bottom = 1936f)
+    private val end = Rect(left = 0f, top = 0f, right = 1080f, bottom = 2400f)
+
+    @Test
+    fun `home transition waits for home capsule instead of stale global coordinates`() {
+        val state = ReadAloudMorphState(Animatable(0f), Density(1f))
+        state.reportScreenBounds(end)
+        state.reportCoverEnd(end, 8f)
+        state.reportPanelStart(start, 24f, anchorKind = CapsuleAnchorKind.Global)
+        state.reportCoverStart(start, 24f, anchorKind = CapsuleAnchorKind.Global)
+        state.expectCapsuleAnchors(CapsuleAnchorKind.HomeBar)
+        assertTrue(!state.hasCapsuleAnchors)
+        assertNull(state.panelFrame())
+        assertNull(state.coverFrame())
+        val home = Rect(1000f, 1850f, 1064f, 1914f)
+        state.reportPanelStart(home, 32f, anchorKind = CapsuleAnchorKind.HomeBar)
+        assertTrue(!state.hasCapsuleAnchors)
+        state.reportCoverStart(home, 28f, anchorKind = CapsuleAnchorKind.HomeBar)
+        assertTrue(state.hasCapsuleAnchors)
+        assertEquals(home, state.panelFrame()!!.bounds)
+        assertEquals(home, state.coverFrame()!!.bounds)
+    }
+
+    @Test
+    fun `disabling capsule while expanded removes geometry before collapse`() {
+        val state = ReadAloudMorphState(Animatable(1f), Density(1f))
+        state.reportScreenBounds(end)
+        state.reportCoverEnd(end, 8f)
+        state.reportPanelStart(start, 24f)
+        state.reportCoverStart(start, 24f)
+        state.clearStartAnchors()
+        assertTrue(!state.hasCapsuleAnchors)
+        assertNull(state.panelFrame())
+        assertNull(state.coverFrame())
+    }
+
+    @Test
+    fun `missing capsule geometry does not invent a collapse destination`() {
+        val state = ReadAloudMorphState(Animatable(0.5f), Density(1f))
+        state.reportScreenBounds(end)
+        state.reportCoverEnd(end, 8f)
+        assertNull(state.panelFrame())
+        assertNull(state.coverFrame())
+    }
+
+    @Test
+    fun `closing a page without cover fades the capsule cover in at its actual location`() =
+        runBlocking {
+            val state = ReadAloudMorphState(Animatable(1f), Density(1f))
+            state.reportCoverStart(start, 24f)
+            state.reportCoverPageVisible(false)
+            // 封面页甚至未测量时也应能从胶囊端点渐显,而不是最后一帧才出现。
+            state.progress.snapTo(0.075f)
+            assertEquals(start, state.coverFrame()!!.bounds)
+            assertEquals(0f, computeMorphFlyingCoverAlpha(0.9f, false), 0f)
+            assertEquals(0f, computeMorphFlyingCoverAlpha(0.15f, false), 0f)
+            assertEquals(0.5f, computeMorphFlyingCoverAlpha(0.075f, false), 0.001f)
+            assertTrue(computeMorphFlyingCoverAlpha(0.01f, false) > 0.98f)
+            assertEquals(1f, computeMorphFlyingCoverAlpha(0.5f, true), 0f)
+            assertEquals(0f, computeMorphFlyingCoverAlpha(0f, false), 0f)
+            assertEquals(0f, computeMorphFlyingCoverAlpha(1f, true), 0f)
+        }
+
+    @Test
+    fun `offscreen pager cover does not replace the measured endpoint or collapse mode`() =
+        runBlocking {
+            val state = ReadAloudMorphState(Animatable(1f), Density(1f))
+            state.reportCoverEnd(end, 8f)
+            state.reportCoverPageVisible(false)
+            state.reportCoverEnd(start, 20f)
+            assertEquals(end, state.coverEndBounds)
+            state.progress.snapTo(0.6f)
+            state.reportCoverPageVisible(true)
+            assertTrue(!state.coverPageVisible)
+            // 预测性返回取消、恢复展开后,重新进入封面页可以恢复正常飞行。
+            state.progress.snapTo(1f)
+            state.reportCoverPageVisible(true)
+            assertTrue(state.coverPageVisible)
+            // 最终测量先于可见性事件到达时,也不会丢掉返回封面页的目标布局。
+            assertEquals(start, state.coverEndBounds)
+            assertEquals(20f, state.coverEndCornerRadiusPx, 0f)
+        }
+
+    @Test
+    fun `capsule appearing during playback supplies the real collapse destination`() = runBlocking {
+        val state = ReadAloudMorphState(Animatable(1f), Density(1f))
+        state.reportScreenBounds(end)
+        state.reportCoverEnd(Rect(250f, 300f, 750f, 1000f), 8f)
+        val capsulePanel = Rect(800f, 1700f, 1000f, 1764f)
+        val capsuleCover = Rect(808f, 1708f, 856f, 1756f)
+        state.reportPanelStart(capsulePanel, 32f)
+        state.reportCoverStart(capsuleCover, 24f)
+
+        assertEquals(capsulePanel, state.panelStartBounds)
+        assertEquals(capsuleCover, state.coverStartBounds)
+        state.progress.snapTo(0.5f)
+        state.reportPanelStart(start, 20f)
+        state.reportCoverStart(start, 20f)
+        assertEquals(capsulePanel, state.panelStartBounds)
+        assertEquals(capsuleCover, state.coverStartBounds)
+        state.progress.snapTo(0f)
+        assertEquals(capsulePanel, state.panelFrame()!!.bounds)
+        assertEquals(capsuleCover, state.coverFrame()!!.bounds)
+    }
+
+    @Test
+    fun `rotation keeps updating without layout while morph geometry stays frozen`() = runBlocking {
+        val state = ReadAloudMorphState(Animatable(0f), Density(1f))
+        state.reportCoverStart(start, 24f, 10f)
+        state.reportCoverEnd(end, 8f)
+        state.progress.snapTo(0.1f)
+        state.reportCoverRotation(80f)
+        assertEquals(start, state.coverStartBounds)
+        assertEquals(24f, state.coverStartCornerRadiusPx, 0f)
+        assertEquals(80f, state.coverRotationDeg, 0f)
+        assertEquals(
+            computeCoverMorphFrame(0.1f, start, end, 24f, 8f, 80f)!!.imageRotationDeg,
+            state.coverFrame()!!.imageRotationDeg,
+            0.001f,
+        )
+    }
+
+    @Test
+    fun `predictive back previews only part of collapse before confirmation`() {
+        assertEquals(1f, computePredictiveMorphProgress(1f, 0f), 0f)
+        assertEquals(0.775f, computePredictiveMorphProgress(1f, 0.5f), 0.001f)
+        assertEquals(0.55f, computePredictiveMorphProgress(1f, 1f), 0.001f)
+        assertEquals(0.55f, computePredictiveMorphProgress(1f, 2f), 0.001f)
+        assertEquals(1f, computePredictiveMorphProgress(1f, -1f), 0f)
+    }
+
+    @Test
+    fun `predictive back starts from the current frame and reverses without a jump`() {
+        val start = 0.7f
+        assertEquals(start, computePredictiveMorphProgress(start, 0f), 0f)
+        val halfway = computePredictiveMorphProgress(start, 0.5f)
+        val further = computePredictiveMorphProgress(start, 0.8f)
+        assertTrue(further < halfway)
+        assertEquals(halfway, computePredictiveMorphProgress(start, 0.5f), 0f)
+        assertEquals(start, computePredictiveMorphProgress(start, 0f), 0f)
+    }
+
+    @Test
+    fun `panel returns material before the capsule endpoint`() {
+        assertEquals(0f, computeMorphPanelAlpha(0f), 0f)
+        assertEquals(0.5f, computeMorphPanelAlpha(0.075f), 0.001f)
+        assertEquals(1f, computeMorphPanelAlpha(0.15f), 0f)
+        assertEquals(1f, computeMorphPanelAlpha(1f), 0f)
+    }
+
+    @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)
+    }
+
+    @Test
+    fun `new source discards stale capsule anchors before opening`() {
+        val state = ReadAloudMorphState(Animatable(0f), Density(1f))
+        state.reportPanelStart(start, 32f)
+        state.reportCoverStart(start, 20f)
+        state.clearStartAnchors()
+        assertEquals(Rect.Zero, state.panelStartBounds)
+        assertEquals(Rect.Zero, state.coverStartBounds)
+        state.reportPanelStart(end, 40f)
+        assertEquals(end, state.panelStartBounds)
+    }
+
+    @Test
+    fun `source changes cannot move an in flight anchor`() = runBlocking {
+        val state = ReadAloudMorphState(Animatable(0f), Density(1f))
+        state.reportPanelStart(start, 32f)
+        state.progress.snapTo(0.5f)
+        state.clearStartAnchors()
+        state.reportPanelStart(end, 40f)
+        assertEquals(start, state.panelStartBounds)
+    }
+
+    @Test
+    fun `panel preserves independent screen corners and reverses continuously`() {
+        val radii = PlayerPanelCornerRadii(80f, 72f, 60f, 64f)
+        val collapsed = computePanelMorphFrame(0f, start, end, 68f, 0f, radii)!!
+        val middle = computePanelMorphFrame(0.5f, start, end, 68f, 0f, radii)!!
+        val expanded = computePanelMorphFrame(1f, start, end, 68f, 0f, radii)!!
+        assertEquals(PlayerPanelCornerRadii.all(68f), collapsed.cornerRadii)
+        assertEquals(PlayerPanelCornerRadii(74f, 70f, 64f, 66f), middle.cornerRadii)
+        assertEquals(radii, expanded.cornerRadii)
+        val nearEnd = computePanelMorphFrame(0.999f, start, end, 68f, 0f, radii)!!
+        assertEquals(expanded.cornerRadii.topLeft, nearEnd.cornerRadii.topLeft, 0.02f)
+    }
+
+    @Test
+    fun `flying cover lands at the page size without a scale jump`() {
+        val coverEnd = Rect(200f, 320f, 760f, 1104f)
+        val frame = computeCoverMorphFrame(1f, Rect(600f, 1800f, 640f, 1840f), coverEnd, 20f, 8f)!!
+        assertEquals(coverEnd, frame.bounds)
+        assertEquals(1f, frame.imageScale, 0f)
+        assertEquals(8f, frame.cornerRadiusPx, 0f)
+        assertEquals(0f, frame.imageRotationDeg, 0f)
+    }
+
+    @Test
+    fun `panel frame matches capsule at zero progress`() {
+        val frame = computePanelMorphFrame(0f, start, end, 32f, 0f)!!
+
+        assertEquals(start, frame.bounds)
+        assertEquals(32f, frame.cornerRadiusPx, 0.001f)
+        assertEquals(1f, frame.shadowAlpha, 0.001f)
+    }
+
+    @Test
+    fun `panel frame matches full screen at full progress`() {
+        val frame = computePanelMorphFrame(1f, start, end, 32f, 0f)!!
+
+        assertEquals(end.left, frame.bounds.left, 0.001f)
+        assertEquals(end.top, frame.bounds.top, 0.001f)
+        assertEquals(end.width, frame.bounds.width, 0.001f)
+        assertEquals(end.height, frame.bounds.height, 0.001f)
+        assertEquals(0f, frame.cornerRadiusPx, 0.001f)
+        assertEquals(0f, frame.shadowAlpha, 0.001f)
+    }
+
+    @Test
+    fun `panel geometry is linear`() {
+        // 「面」不参与缓动:缓动全交给驱动它的进度动画,再叠一层就是双重缓动。
+        val quarter = computePanelMorphFrame(0.25f, start, end, 32f, 0f)!!
+
+        assertEquals(start.left + (end.left - start.left) * 0.25f, quarter.bounds.left, 0.01f)
+        assertEquals(start.top + (end.top - start.top) * 0.25f, quarter.bounds.top, 0.01f)
+        assertEquals(start.right + (end.right - start.right) * 0.25f, quarter.bounds.right, 0.01f)
+        assertEquals(
+            start.bottom + (end.bottom - start.bottom) * 0.25f,
+            quarter.bounds.bottom,
+            0.01f
+        )
+    }
+
+    @Test
+    fun `cover moves x and y on different curves`() {
+        // 封面:X 走位置曲线(先快),Y 与尺寸一起走尺寸曲线(先慢)。
+        // 这是「从胶囊里长出来」而不是「从胶囊里滑出来」的关键,不能用同一条曲线。
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+        val frame = computeCoverMorphFrame(0.5f, coverStart, coverEnd, 20f, 8f)!!
+
+        val e1 = morphPositionEase(0.5f)
+        val e2 = morphSizeEase(0.5f)
+        assertTrue("位置曲线应比尺寸曲线快", e1 > e2)
+        assertEquals(
+            coverStart.center.x + (coverEnd.center.x - coverStart.center.x) * e1,
+            frame.bounds.center.x,
+            0.01f,
+        )
+        assertEquals(
+            coverStart.center.y + (coverEnd.center.y - coverStart.center.y) * e2,
+            frame.bounds.center.y,
+            0.01f,
+        )
+        assertEquals(
+            coverStart.height + (coverEnd.height - coverStart.height) * e2,
+            frame.bounds.height,
+            0.01f,
+        )
+    }
+
+    @Test
+    fun `cover keeps circular radius clamp during morph`() {
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+        val frame = computeCoverMorphFrame(0.5f, coverStart, coverEnd, 20f, 8f)!!
+
+        assertTrue(frame.cornerRadiusPx <= minOf(frame.bounds.width, frame.bounds.height) / 2f)
+        assertTrue(frame.imageScale in 1f..1.01f)
+    }
+
+    @Test
+    fun `cover rotation falls back to zero while growing`() {
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+
+        assertEquals(
+            180f,
+            computeCoverMorphFrame(0f, coverStart, coverEnd, 20f, 8f, 180f)!!.imageRotationDeg,
+            0.001f,
+        )
+        assertEquals(
+            0f,
+            computeCoverMorphFrame(1f, coverStart, coverEnd, 20f, 8f, 180f)!!.imageRotationDeg,
+            0.001f,
+        )
+    }
+
+    @Test
+    fun `accumulated cover rotation is normalized to the shortest path`() {
+        // 胶囊封面每 12 秒 +360,跑一小时就攒到几万度;
+        // 不归一化的话收起时飞行封面会顺着插值把攒下的每一圈都转一遍。
+        assertEquals(120f, normalizeMorphRotationDeg(4_440f), 0.001f)
+        assertEquals(-170f, normalizeMorphRotationDeg(190f), 0.001f)
+        assertEquals(0f, normalizeMorphRotationDeg(1_080f), 0.001f)
+        assertEquals(0f, normalizeMorphRotationDeg(Float.NaN), 0.001f)
+
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+        val frame = computeCoverMorphFrame(0f, coverStart, coverEnd, 20f, 8f, 4_440f)!!
+        assertEquals(120f, frame.imageRotationDeg, 0.001f)
+    }
+
+    @Test
+    fun `cover rotation never exceeds half a turn while morphing`() {
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+
+        for (turns in 0..40) {
+            for (step in 0..10) {
+                val frame = computeCoverMorphFrame(
+                    progress = step / 10f,
+                    start = coverStart,
+                    end = coverEnd,
+                    startCornerRadiusPx = 20f,
+                    endCornerRadiusPx = 8f,
+                    imageRotationDeg = turns * 360f + step * 36f,
+                )!!
+                assertTrue(
+                    "旋转角必须落在半圈以内:${frame.imageRotationDeg}",
+                    frame.imageRotationDeg in -180f..180f,
+                )
+            }
+        }
+    }
+
+    @Test
+    fun `cover rotation settles before the content starts fading in`() {
+        val coverStart = Rect(0f, 0f, 40f, 40f)
+        val coverEnd = Rect(0f, 0f, 560f, 784f)
+
+        // 飞行封面与播放页真实封面在 MORPH_VEIL_START 之后交叉淡入,
+        // 那时旋转必须已经停住,否则交接处会「转着落地」。
+        val frame = computeCoverMorphFrame(
+            progress = MORPH_VEIL_START,
+            start = coverStart,
+            end = coverEnd,
+            startCornerRadiusPx = 20f,
+            endCornerRadiusPx = 8f,
+            imageRotationDeg = 170f,
+        )!!
+        assertEquals(0f, frame.imageRotationDeg, 0.001f)
+    }
+
+    @Test
+    fun `collapse keeps player background until the capsule end of the path`() {
+        assertEquals(1f, computeMorphFaceBlend(1f), 0f)
+        assertEquals(1f, computeMorphFaceBlend(0.85f), 0f)
+        assertEquals(1f, computeMorphFaceBlend(0.5f), 0f)
+        assertEquals(1f, computeMorphFaceBlend(0.15f), 0f)
+        assertEquals(0.5f, computeMorphFaceBlend(0.075f), 0.001f)
+        assertEquals(0f, computeMorphFaceBlend(0f), 0f)
+    }
+
+    @Test
+    fun `settle velocity approaches zero near capsule to prevent endpoint crossing`() {
+        assertEquals(-3f, computeMorphSettleVelocity(1f, 0f, -20f), 0f)
+        val nearEnd = computeMorphSettleVelocity(0.01f, 0f, -3f)
+        assertTrue(nearEnd > -0.17f && nearEnd < 0f)
+        assertEquals(0f, computeMorphSettleVelocity(0f, 0f, -3f), 0f)
+    }
+
+    @Test
+    fun `precise spring residual leaves less than a pixel of cover displacement`() {
+        val nearEnd = computeCoverMorphFrame(0.0001f, start, end, 32f, 8f)!!
+        val atEnd = computeCoverMorphFrame(0f, start, end, 32f, 8f)!!
+        assertEquals(atEnd.bounds.left, nearEnd.bounds.left, 0.5f)
+        assertEquals(atEnd.bounds.top, nearEnd.bounds.top, 0.5f)
+    }
+
+    @Test
+    fun `frames are null when either anchor is missing`() {
+        assertNull(computePanelMorphFrame(0.5f, Rect.Zero, end, 32f, 0f))
+        assertNull(computePanelMorphFrame(0.5f, start, Rect.Zero, 32f, 0f))
+        assertNull(computeCoverMorphFrame(0.5f, Rect.Zero, end, 20f, 8f))
+    }
+
+    @Test
+    fun `veil stays hidden until the last stretch`() {
+        assertEquals(0f, computeMorphVeil(0f), 0.001f)
+        assertEquals(0f, computeMorphVeil(MORPH_VEIL_START), 0.001f)
+        assertEquals(0f, computeMorphVeil(0.5f), 0.001f)
+        assertEquals(1f, computeMorphVeil(1f), 0.001f)
+        assertTrue(computeMorphVeil(0.925f) in 0.4f..0.6f)
+    }
+}

+ 27 - 0
app/src/test/java/io/legado/app/ui/main/MainPlayerRouteRestoreTest.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.main
+
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MainPlayerRouteRestoreTest {
+    @Test
+    fun oldReadAloudPlayerStackStillDeserializesAfterUpgrade() {
+        val saved =
+            """[{"type":"io.legado.app.ui.main.MainRouteHome"},{"type":"io.legado.app.ui.main.MainRouteReadAloudPlayer"}]"""
+        assertEquals(
+            listOf(MainRouteHome, MainRouteReadAloudPlayer),
+            Json.decodeFromString<List<MainRoute>>(saved)
+        )
+    }
+
+    @Test
+    fun oldAudioPlayerStackRetainsItsBookAndBookshelfFlag() {
+        val saved =
+            """[{"type":"io.legado.app.ui.main.MainRouteHome"},{"type":"io.legado.app.ui.main.MainRouteAudioPlay","bookUrl":"audio-a","inBookshelf":false}]"""
+        assertEquals(
+            listOf(MainRouteHome, MainRouteAudioPlay("audio-a", false)),
+            Json.decodeFromString<List<MainRoute>>(saved)
+        )
+    }
+}

+ 68 - 0
app/src/test/java/io/legado/app/ui/main/PlaybackPlayerAnchorsTest.kt

@@ -0,0 +1,68 @@
+package io.legado.app.ui.main
+
+import io.legado.app.domain.model.PlaybackCapsuleSource
+import io.legado.app.domain.model.PlaybackCapsuleState
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PlaybackPlayerAnchorsTest {
+    private val audio = PlaybackCapsuleState(
+        source = PlaybackCapsuleSource.AudioBook,
+        bookUrl = "audio-a",
+    )
+
+    @Test
+    fun currentAudiobookUsesItsCapsule() {
+        assertTrue(capsuleMatchesPlayer(audio, PlaybackCapsuleSource.AudioBook, "audio-a"))
+    }
+
+    @Test
+    fun differentAudiobookDoesNotFlyThePreviousCover() {
+        assertFalse(capsuleMatchesPlayer(audio, PlaybackCapsuleSource.AudioBook, "audio-b"))
+    }
+
+    @Test
+    fun notificationWithoutBookUrlUsesCurrentAudioSession() {
+        assertTrue(capsuleMatchesPlayer(audio, PlaybackCapsuleSource.AudioBook, ""))
+    }
+
+    @Test
+    fun readAloudCapsuleCannotAnimateIntoAudioPlayer() {
+        val readAloud = audio.copy(source = PlaybackCapsuleSource.ReadAloud)
+        assertFalse(capsuleMatchesPlayer(readAloud, PlaybackCapsuleSource.AudioBook, "audio-a"))
+        assertFalse(capsuleMatchesPlayer(audio, PlaybackCapsuleSource.ReadAloud, ""))
+    }
+
+    @Test
+    fun stoppedSessionUsesFallbackRatherThanStaleAnchors() {
+        assertFalse(
+            capsuleMatchesPlayer(
+                audio.copy(source = null),
+                PlaybackCapsuleSource.AudioBook,
+                "audio-a"
+            )
+        )
+    }
+
+    @Test
+    fun hiddenBottomBarAlwaysUsesGlobalCapsuleEvenIfFloatingBarIsEnabled() {
+        assertFalse(shouldUseHomePlaybackCapsule(true, false, true, false))
+        assertTrue(shouldUseHomePlaybackCapsule(true, true, true, false))
+    }
+
+    @Test
+    fun otherRoutesRailAndStandardBottomBarUseGlobalCapsule() {
+        assertFalse(shouldUseHomePlaybackCapsule(false, true, true, false))
+        assertFalse(shouldUseHomePlaybackCapsule(true, true, true, true))
+        assertFalse(shouldUseHomePlaybackCapsule(true, true, false, false))
+    }
+
+    @Test
+    fun backNeverPopsTheCoveredRouteDuringPlayerOpeningOrClosing() {
+        assertFalse(shouldHandleActivityBack(false, playerPresent = true))
+        assertFalse(shouldHandleActivityBack(true, playerPresent = true))
+        assertTrue(shouldHandleActivityBack(false, playerPresent = false))
+        assertFalse(shouldHandleActivityBack(true, playerPresent = false))
+    }
+}

+ 13 - 15
app/src/test/java/io/legado/app/ui/main/ReadAloudSwitchToClassicTargetTest.kt

@@ -6,47 +6,45 @@ import org.junit.Assert.assertTrue
 import org.junit.Test
 
 /**
- * 听书播放页「经典控制」按钮的目标判定。
+ * 听书播放弹层「经典控制」按钮的目标判定。
  *
- * 规则:上一站是阅读界面 → 回到已有阅读界面并打开经典朗读控制;否则 → 打开阅读界面。
- * 之前该按钮发出的 `ReturnToClassic` 效果没有任何收集方,点了没反应。
+ * 规则:栈顶是阅读界面 → 把请求交给它并打开经典朗读控制;否则 → 打开阅读界面。
+ * 播放弹层是全局浮层、不在导航栈上,所以判据是「当前顶层」而不是「上一站」。
  */
 class ReadAloudSwitchToClassicTargetTest {
 
     @Test
-    fun `returns to existing reader when immediate parent is read book`() {
+    fun `hands request to existing reader when reader is on top`() {
         val backStack: List<NavKey> = listOf(
             MainRouteHome,
             MainRouteReadBook(bookUrl = "book://a"),
-            MainRouteReadAloudPlayer,
         )
 
-        assertTrue(hasReadBookParent(backStack))
+        assertTrue(isReaderOnTop(backStack))
     }
 
     @Test
-    fun `opens reader when parent is home`() {
-        val backStack: List<NavKey> = listOf(MainRouteHome, MainRouteReadAloudPlayer)
+    fun `opens reader when home is on top`() {
+        val backStack: List<NavKey> = listOf(MainRouteHome)
 
-        assertFalse(hasReadBookParent(backStack))
+        assertFalse(isReaderOnTop(backStack))
     }
 
     @Test
-    fun `opens reader when player is the only entry`() {
-        val backStack: List<NavKey> = listOf(MainRouteReadAloudPlayer)
+    fun `opens reader when stack is empty`() {
+        val backStack: List<NavKey> = emptyList()
 
-        assertFalse(hasReadBookParent(backStack))
+        assertFalse(isReaderOnTop(backStack))
     }
 
     @Test
-    fun `opens reader when read book is not the immediate parent`() {
+    fun `opens reader when reader is not the top entry`() {
         val backStack: List<NavKey> = listOf(
             MainRouteHome,
             MainRouteReadBook(bookUrl = "book://a"),
             MainRouteBookInfo(name = null, author = null, bookUrl = "book://a"),
-            MainRouteReadAloudPlayer,
         )
 
-        assertFalse(hasReadBookParent(backStack))
+        assertFalse(isReaderOnTop(backStack))
     }
 }

+ 127 - 0
app/src/test/java/io/legado/app/ui/widget/components/FloatingBottomBarInteractionTest.kt

@@ -0,0 +1,127 @@
+package io.legado.app.ui.widget.components
+
+import android.app.Application
+import android.os.Looper
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.unit.dp
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.ui.theme.AppTheme
+import io.legado.app.ui.widget.components.text.AppText
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.RobolectricTestRunner
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class FloatingBottomBarInteractionTest {
+    @Test
+    fun physicalMenuTapRestoresNavigationInsteadOfHittingTheTransparentTabs() =
+        verifyDockTap(1f, 0.5f, expectedRestores = 1, expectedTabClicks = 0)
+
+    @Test
+    fun menuTapCanReverseAnOpeningAnimation() =
+        verifyDockTap(0.6f, 0.5f, expectedRestores = 1, expectedTabClicks = 0)
+
+    @Test
+    fun normalNavigationStillHandlesOneTapAfterRemovingSamplingInteractions() =
+        verifyDockTap(0f, 0.375f, expectedRestores = 0, expectedTabClicks = 1)
+
+    private fun verifyDockTap(
+        compactProgress: Float,
+        targetFraction: Float,
+        expectedRestores: Int,
+        expectedTabClicks: Int,
+    ) {
+        val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible()
+        val activity = controller.get()
+        var restores = 0
+        var tabClicks = 0
+        var bounds = Rect.Zero
+        try {
+            activity.setContent {
+                CompositionLocalProvider(LocalInspectionMode provides true) {
+                    AppTheme(AppUiConfiguration(), applyBackground = false) {
+                        Box(Modifier.fillMaxSize()) {
+                            FloatingBottomBar(
+                                modifier = Modifier
+                                    .width((312f - 248f * compactProgress).dp)
+                                    .onGloballyPositioned { bounds = it.boundsInWindow() },
+                                selectedIndex = { 0 },
+                                onSelected = { tabClicks++ },
+                                tabsCount = 4,
+                                backdrop = rememberLayerBackdrop(),
+                                isBlurEnabled = false,
+                                compactProgress = compactProgress,
+                                expandedWidth = 312.dp,
+                                onRestoreNavigation = { restores++ },
+                            ) {
+                                repeat(4) {
+                                    FloatingBottomBarItem(
+                                        onClick = { tabClicks++ },
+                                        enabled = compactProgress == 0f
+                                    ) {
+                                        AppText("Tab $it")
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            val decor = activity.window.decorView
+            repeat(5) {
+                shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+                decor.measure(
+                    View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+                    View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY)
+                )
+                decor.layout(0, 0, 1080, 1920)
+            }
+            assertFalse(
+                "The real dock must be laid out before sending touch events",
+                bounds.isEmpty
+            )
+            val downTime = SystemClock.uptimeMillis()
+            MotionEvent.obtain(
+                downTime, downTime, MotionEvent.ACTION_DOWN,
+                bounds.left + bounds.width * targetFraction, bounds.center.y, 0
+            ).also {
+                activity.dispatchTouchEvent(it)
+                it.recycle()
+            }
+            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+            MotionEvent.obtain(
+                downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
+                bounds.left + bounds.width * targetFraction, bounds.center.y, 0
+            ).also {
+                activity.dispatchTouchEvent(it)
+                it.recycle()
+            }
+            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+            assertEquals(expectedRestores, restores)
+            assertEquals(expectedTabClicks, tabClicks)
+        } finally {
+            controller.pause().stop().destroy()
+        }
+    }
+}

+ 73 - 0
docs/dev/readaloud-player-morph.md

@@ -0,0 +1,73 @@
+# 朗读播放页与悬浮胶囊
+
+本文记录当前项目实现的职责与验收路径,不包含外部项目的分析材料。
+
+## 职责
+
+- `MainActivity` 组装同窗口播放浮层;主页启用悬浮底栏、底栏显示且不使用侧边导航时显示圆形入口,其余页面使用可拖拽胶囊。
+- `core/ui/player/PlayerMorphHost` 共用于朗读和有声书,管理布局锚点、飞行封面、返回与下拉手势;
+  `PlayerMorphCover` 统一终点封面的请求与交接;`ReadAloudMorphState` 是唯一动画进度源。朗读宿主只保留朗读设置与经典控制动作。
+- `ReadAloudCapsule` 共用于应用内与应用外;`ReadAloudOverlayWindow` 负责 Android
+  窗口、权限检查和生命周期,附着现有朗读或有声书服务,复用已有播放状态。
+- `PlaybackCapsuleGateway` 统一胶囊所需的封面、进度与控制动作;Android
+  适配器监听既有播放事件,不初始化播放器或加载目录。播放中的会话优先,都暂停时保留当前会话,停止后回退到另一存活会话。
+- 主页、应用内和应用外胶囊共用相同选择规则、显示开关和保存坐标。应用外仅由当前会话所属服务挂载窗口,避免双窗口。
+- 两种胶囊均打开 Activity 同窗口形变播放页,不再用全屏底部弹层,且不改变原导航栈。书架、详情、通知和遗留
+  Intent 的有声书入口也汇入同一宿主;有声书的歌词、目录、倍速、播放模式、定时、换源和媒体回调保留。
+- 有声书封面保留用户选择的比例(包含圆形),目标矩形和圆角由实际布局上报。面板使用所在页面胶囊的真实锚点;只有同一引擎、同一本书的封面参与飞行,不同会话的封面淡出交接。
+- 浮层结束后释放有声书页面 ViewModel 和 Activity
+  回调,已加载的有声书即使尚未启动服务,也保留为可恢复的暂停会话;显式停止时清除准备会话。服务创建时记录书籍归属,销毁时只清除仍匹配该书的准备会话;服务存活期间切书或旧服务回调晚到时,新书准备会话保留。重新打开同步当前书籍。切换播放器先完成旧浮层收起,保证只有一个宿主驱动进度。
+- 应用外窗口仅在胶囊开关开启、应用无可见 Activity 且拥有系统悬浮窗权限时显示。回到应用、停止服务或撤销权限时移除。
+
+- 旧 `MainRouteReadAloudPlayer` 与 `MainRouteAudioPlay` 保留序列化类型和占位
+  entry,恢复旧栈时转入同窗口浮层并移除占位。播放浮层打开及退场期间清空下层导航与胶囊语义,收起结束后恢复。
+
+## 主页长按控制
+
+- 长按主页封面入口,左侧悬浮导航收为 64dp 圆形菜单按钮,右侧播放胶囊展开;点击菜单或再次长按展开态封面同步恢复。
+- 两侧宽度由同一个临界阻尼弹簧进度驱动,胶囊高度保持
+  64dp,封面复用同一个节点,信息与按钮按展开进度显露。导航内容保持原宽度测量后渐隐,过渡期间禁用隐藏的导航项。背景采样副本只参与绘制和测量,不创建点击节点;菜单在根容器最上层接收触摸。
+- 展开内容包括书名、实际播放章节、章节进度、暂停/继续和退出。朗读章节由服务发布到会话状态,有声书章节复用播放模型,均经
+  Gateway 投影;UI 不读取播放服务或数据库。
+- 展开后的面板与封面继续上报真实布局端点,短按进入播放页保留原有形变。退出会话、隐藏胶囊或退出主页底栏宿主时恢复导航。
+
+## 动画与位置
+
+- 展开、收起与手势结算使用临界阻尼弹簧:刚度 260,阈值 0.0001;预测性返回手势最多预览 45%
+  收起,确认后完成,取消后恢复。关闭预测性返回开关时,仅在确认返回后收起播放浮层;下层导航不处理这次返回。
+- 面板始终按宿主尺寸布局,仅在形变期间于最终坐标裁剪独立四角圆角,完全展开后背景和内容层均取消裁剪,开始收起时重新启用。API
+  31+ 使用系统公开圆角数据;未报告圆角时使用零半径。
+-
+
+形变期间冻结胶囊端点;完全展开后重新接收真实位置,覆盖先开播放页、后启动服务才出现胶囊的情况。首次打开时,胶囊宿主提前按保存位置布局同一个不可见组件,等待真实端点连续两帧稳定后开始。主页显隐独立于封面关联资格,最多等待
+60
+个布局帧;仍缺失锚点或目标布局时固定本次展开为无起点淡入,不虚构坐标,也不在途中接入迟到的锚点。关闭胶囊开关时立即使用淡入淡出,不等待布局;封面未关联当前播放会话时,只等待胶囊与面板布局,不等待封面飞行终点。
+- 两端封面让位给单一飞行封面。旋转角独立同步,封面统一解码尺寸,封面页保持无交叉淡入的飞行交接。
+- 目录或歌词页没有可见封面时,返回使用实际胶囊封面位置,末段(进度 0.15..0)按材质交接曲线平滑渐显,不从屏幕外的
+  Pager 封面位置飞回。页面可见性在手势/动画中冻结,返回取消后重新同步。
+- 收起末段(展开进度 0..0.15)交还胶囊底色与进度圆环;内容淡入区间为 0.85..1。形变期间关闭胶囊阴影,结束后用
+  180ms 恢复。
+- 迷你与展开态共用单一内容节点和 320ms 展开进度;按钮保持完整测量尺寸,连续揭示宽度。迷你封面为
+  48dp,播放时每圈 20 秒,暂停时停转。
+- 保存坐标为相对屏幕底部中央的中心偏移,单位 dp,默认中心距底部 72dp。松手时距左右边缘不超过 72dp
+  则吸附,迷你封面中心位于边线,外侧半张位于屏幕外;展开时临时向内让位。
+- 系统窗口使用 `WRAP_CONTENT`、非焦点与非触摸模态标志,主题不绘制全屏应用背景;窗口允许部分移出屏幕。
+
+## 验证
+
+自动回归覆盖形变端点、四角插值、背景交接、返回进度映射、旋转同步、窗口高度变化、位置往返、权限与显示条件、左右吸附和连续位置采样。
+
+```powershell
+.\gradlew.bat :app:assembleAppDebug :app:testAppDebugUnitTest --tests "io.legado.app.service.readaloud.ReadAloudOverlayWindowTest" --tests "io.legado.app.service.readaloud.PlaybackCapsuleSelectionTest" --tests "io.legado.app.service.readaloud.PlaybackCapsuleGatewayAdapterTest" --tests "io.legado.app.ui.book.readaloud.morph.PlayerMorphFramesTest" --tests "io.legado.app.ui.main.PlaybackPlayerAnchorsTest" --tests "io.legado.app.ui.book.audio.AudioPlayViewModelInitializationTest" --no-configuration-cache
+git diff --check
+```
+
+设备验收应覆盖:有声书书架/详情/通知/胶囊入口、各封面比例、歌词/目录页下拉、定时/换源弹层返回、主页与阅读页开合、首次启动朗读、动画反向、系统返回完成与取消、按钮展开、暂停旋转、外部应用触摸透传、权限撤销以及最后几帧封面与背景交接。编译和几何测试不替代动画观感与设备帧时间验证。
+
+## 审查边界
+
+- 当前吸附位置保存为数值横向偏移,不单独保存左右边。屏幕宽度增大后原贴边位置可能成为自由位置;旋转或分屏保持吸附边尚未实现。
+- 拖拽取消仅结束本地拖动,没有提交当前位置;若窗口或宿主随后销毁,重新挂载会恢复上次成功松手的位置。
+- 应用内定位以物理屏幕尺寸为基准,多窗口或小于屏幕的宿主可能无法完整显示该位置,仍需设备验证。
+- 此前 `lintAppDebug` 在 `ModifierParameterDetector` 分析 `MainScreen.kt` 时发生检查器异常,尚无通过的
+  lint 结果;没有关闭检查或放宽基线。

+ 7 - 14
gradle.properties

@@ -8,21 +8,15 @@
 # The setting is particularly useful for tweaking memory settings.
 # Default value: -Xmx512m -Xms256m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError
 # Bugs: This will override important JVM argument defaults. Include all important JVM arguments if you wan to change arguments. See https://github.com/gradle/gradle/issues/19750
-# 内存预算(32G 机器实测):Gradle daemon 6g + Kotlin daemon 4g + lint/编译 worker ≈ 8g,
-# 峰值 ~18g,为 IDE、浏览器和系统留出余量。
-# 之前是 -Xmx12g,叠加 kotlin.daemon 的 -Xms3g 与 16 并发 worker 后会同时向系统申请
-# 近 20G 提交内存,AGP lint 阶段直接出现 “Native memory allocation (mmap) failed for G1
-# virtual space”,Gradle daemon 被原生 OOM 干掉。这里把单个 JVM 的上限收回来,
-# 并配合 org.gradle.workers.max 限制并发进程数。
-org.gradle.jvmargs=-Xmx6g -Xms1g -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseG1GC
+# 32GB 双工程并行构建起始预算:每个工程 Gradle 4g + Kotlin 3g。
+# 堆上限不包含 worker、元空间和原生内存;完整构建峰值仍需实测。
+org.gradle.jvmargs=-Xmx4g -Xms512m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseG1GC
 # When configured, Gradle will run in incubating parallel mode.
 # This option should only be used with decoupled projects. More details, visit
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
 org.gradle.parallel=true
-# 并发 worker 进程上限。默认取 CPU 核数(本机 16),AGP lint / Kotlin 编译会各自 fork JVM,
-# 核数越多同时存活的 JVM 越多,内存被放大得越厉害;6 是本机实测能稳定跑完
-# `lintAppDebug` 与完整测试集的取值。
-org.gradle.workers.max=6
+# 限制每次构建的并发工作数量,降低两个工程同时构建的内存峰值。
+org.gradle.workers.max=2
 # AndroidX package structure to make it clearer which packages are bundled with the
 # Android operating system, and which are packaged with your app's APK
 # https://developer.android.com/topic/libraries/support-library/androidx-rn
@@ -59,8 +53,7 @@ org.gradle.java.installations.auto-detect=true
 org.gradle.java.installations.auto-download=false
 # 构建缓存:任务输入不变时复用输出,加速 clean build / 切换分支 / CI
 org.gradle.caching=true
-# Kotlin 守护进程内存:legado Kotlin 代码量大,默认偏小会导致 daemon 重启与 GC 停顿。
-# 4g 上限足够;-Xms 从 3g 降到 512m,避免 daemon 一启动就向系统真实提交 3G 内存。
-kotlin.daemon.jvmargs=-Xmx4g -Xms512m -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
+# 显式设置 Kotlin 堆上限,避免继承 Gradle 的最大堆。
+kotlin.daemon.jvmargs=-Xmx3g -Xms256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC
 # Gradle 9.4+:显式开启 Tooling API 并行(IDE sync),避免依赖 org.gradle.parallel 的弃用隐式推导
 org.gradle.tooling.parallel=true