HapeLee 1 месяц назад
Родитель
Сommit
7497730282

+ 7 - 5
app/src/main/java/io/legado/app/service/playback/PlaybackCapsuleGatewayAdapter.kt

@@ -88,12 +88,14 @@ class PlaybackCapsuleGatewayAdapter(
     }
 
     private fun readAloudSnapshot(): PlaybackCapsuleState {
-        if (!availability.value.first) return PlaybackCapsuleState()
-        val book = ReadBook.book ?: return PlaybackCapsuleState()
+        // A previous service's delayed teardown can clear availability after a new service
+        // started. The actual running service owns the session; keep its capsule visible.
+        if (!BaseReadAloudService.isRun) return PlaybackCapsuleState()
+        val book = ReadBook.book
         val session = sessionStore.state.value
         return PlaybackCapsuleState(
-            PlaybackCapsuleSource.ReadAloud, book.bookUrl, book.name,
-            book.author, book.getDisplayCover(), book.origin,
+            PlaybackCapsuleSource.ReadAloud, book?.bookUrl.orEmpty(), book?.name.orEmpty(),
+            book?.author.orEmpty(), book?.getDisplayCover(), book?.origin,
             session.status != ReadAloudSessionStatus.Playing,
             (session.playback.chapterPosition.toFloat() / session.playback.chapterLength.coerceAtLeast(
                 1
@@ -156,7 +158,7 @@ class PlaybackCapsuleGatewayAdapter(
     private fun ownsSelectedSession(source: PlaybackCapsuleSource): Boolean =
         state.value.source == source &&
                 when (source) {
-                    PlaybackCapsuleSource.ReadAloud -> availability.value.first && BaseReadAloudService.isRun
+                    PlaybackCapsuleSource.ReadAloud -> BaseReadAloudService.isRun
                     PlaybackCapsuleSource.AudioBook -> (availability.value.second && AudioPlayService.isRun) ||
                             (AudioPlay.book?.bookUrl != null && preparedAudioBookUrl.value == AudioPlay.book?.bookUrl)
                 }

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

@@ -72,6 +72,7 @@ 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.computeCapsuleCoverAlpha
 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
@@ -388,7 +389,7 @@ private fun CapsuleContent(
                 }
                 .graphicsLayer {
                     rotationZ = coverRotation.value
-                    alpha = if ((coverMorph?.progress?.value ?: 0f) <= 0f) 1f else 0f
+                    alpha = computeCapsuleCoverAlpha(coverMorph?.progress?.value ?: 0f)
                 }
                 .clip(CircleShape)
                 .then(if (expansion < 1f) Modifier.semantics {

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

@@ -71,6 +71,8 @@ fun ReadAloudConfigContent(
         HorizontalPager(
             state = pagerState,
             verticalAlignment = Alignment.Top,
+            // 只有两页,手势频繁停在边界;保留平台 stretch 过冲会在松手后反向回弹。
+            overscrollEffect = null,
             modifier = Modifier
                 .fillMaxWidth()
                 .weight(1f, fill = false),

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

@@ -331,6 +331,8 @@ private fun ReaderBookSheet(
         )
         HorizontalPager(
             state = pagerState,
+            // Sheet 内到边界时,平台 stretch 过冲会在松手后反向回弹。
+            overscrollEffect = null,
             modifier = Modifier
                 .fillMaxWidth()
                 .weight(1f),

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

@@ -144,6 +144,8 @@ private fun MoreActionsPager(
         val rowCount = if (actions.size > 4) 2 else 1
         HorizontalPager(
             state = pagerState,
+            // 页数很少,手势频繁停在边界;保留平台 stretch 过冲会在松手后反向回弹。
+            overscrollEffect = null,
             modifier = Modifier
                 .fillMaxWidth()
                 .height(

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

@@ -173,6 +173,8 @@ fun TextProcessingSheet(
         )
         HorizontalPager(
             state = pagerState,
+            // Sheet 内到边界时,平台 stretch 过冲会在松手后反向回弹。
+            overscrollEffect = null,
             modifier = Modifier
                 .fillMaxWidth()
                 .weight(1f),

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

@@ -6,18 +6,18 @@ import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 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.CompositionLocalProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 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.gateway.PlaybackCapsuleGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 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.morph.LocalReadAloudMorph
 import org.koin.compose.koinInject
 
 /** 应用内播放胶囊宿主,共用朗读和有声书状态;播放器初始化仍由各自页面负责。 */
@@ -64,7 +64,7 @@ fun ReadAloudShellHost(
                     onPositionChanged = onCapsulePositionChanged,
                     onTogglePause = { playerState.source?.let(playbackGateway::togglePause) },
                     onStop = { playerState.source?.let(playbackGateway::stop) },
-                    onOpenPlayer = { onOpenPlayer(playerState) },
+                    onOpenPlayer = { if (!previewOnly) onOpenPlayer(playerState) },
                 )
             }
         }

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

@@ -239,6 +239,9 @@ fun computeMorphFaceBlend(
 /** 接近胶囊时交还原始材质,避免进度归零时才把纯色面切换成玻璃。 */
 fun computeMorphPanelAlpha(progress: Float): Float = smoothstep(progress / 0.15f)
 
+/** 胶囊封面与播放面使用同一淡入/淡出曲线,收起末段连续交接。 */
+fun computeCapsuleCoverAlpha(progress: Float): Float = 1f - computeMorphPanelAlpha(progress)
+
 /** 无封面页面返回时,封面留在胶囊端点,随末段材质交接平滑渐显。 */
 fun computeMorphFlyingCoverAlpha(progress: Float, coverPageVisible: Boolean): Float {
     if (progress <= 0f || progress >= 1f) return 0f

+ 24 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.searchContent
 
+import androidx.activity.compose.PredictiveBackHandler
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.clickable
@@ -53,6 +54,7 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.SearchContentHistory
+import io.legado.app.ui.main.LocalSearchOverlayAnimation
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
@@ -73,13 +75,19 @@ import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 
+/**
+ * 搜索正文是压在阅读界面之上的叠层目的地;进出动画由导航 Scene 管理。
+ */
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SearchContentRouteScreen(
     onBack: () -> Unit,
+    isTopRoute: Boolean = true,
+    predictiveBackEnabled: Boolean = true,
     autoFocus: Boolean = true,
     viewModel: SearchContentViewModel = koinViewModel()
 ) {
@@ -94,6 +102,22 @@ fun SearchContentRouteScreen(
     DisposableEffect(viewModel) {
         onDispose { viewModel.onIntent(SearchContentIntent.LeaveSearch) }
     }
+    val overlayAnimation = LocalSearchOverlayAnimation.current
+    val backScope = rememberCoroutineScope()
+    // Nav3 场景持有进退场状态;页面只把系统手势进度送给当前场景,完成后再退栈。
+    PredictiveBackHandler(enabled = isTopRoute) { events ->
+        try {
+            events.collect { event ->
+                if (predictiveBackEnabled) overlayAnimation?.previewBack(event.progress)
+            }
+            onBack()
+        } catch (cancelled: CancellationException) {
+            if (predictiveBackEnabled && isTopRoute) {
+                backScope.launch { overlayAnimation?.cancelPreview() }
+            }
+            throw cancelled
+        }
+    }
     SearchContentScreen(
         state = state,
         autoFocus = autoFocus,

+ 28 - 25
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -619,32 +619,32 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                                 rememberViewModelStoreNavEntryDecorator(),
                             ),
                             sceneStrategies = listOf(
-                                ModalOverlaySceneStrategy(),
+                                remember { ModalOverlaySceneStrategy() },
                                 SinglePaneSceneStrategy(),
                             ),
                             transitionSpec = {
                                 (slideIntoContainer(
                                     towards = AnimatedContentTransitionScope.SlideDirection.Start,
                                     animationSpec = tween(
-                                        durationMillis = 480,
+                                        durationMillis = NAV_SLIDE_DURATION_MILLIS,
                                         easing = FastOutSlowInEasing
                                     ),
                                     initialOffset = { fullWidth -> fullWidth }
                                 ) + fadeIn(
                                     animationSpec = tween(
-                                        durationMillis = 360,
+                                        durationMillis = NAV_FADE_DURATION_MILLIS,
                                         easing = LinearOutSlowInEasing
                                     )
                                 )) togetherWith (slideOutOfContainer(
                                     towards = AnimatedContentTransitionScope.SlideDirection.Start,
                                     animationSpec = tween(
-                                        durationMillis = 480,
+                                        durationMillis = NAV_SLIDE_DURATION_MILLIS,
                                         easing = FastOutSlowInEasing
                                     ),
                                     targetOffset = { fullWidth -> fullWidth / 4 }
                                 ) + fadeOut(
                                     animationSpec = tween(
-                                        durationMillis = 360,
+                                        durationMillis = NAV_FADE_DURATION_MILLIS,
                                         easing = LinearOutSlowInEasing
                                     )
                                 ))
@@ -653,22 +653,22 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                                 (slideIntoContainer(
                                     towards = AnimatedContentTransitionScope.SlideDirection.Start,
                                     animationSpec = tween(
-                                        durationMillis = 480,
+                                        durationMillis = NAV_SLIDE_DURATION_MILLIS,
                                         easing = FastOutSlowInEasing
                                     ),
                                     initialOffset = { fullWidth -> -fullWidth / 4 }
                                 ) + fadeIn(
                                     animationSpec = tween(
-                                        durationMillis = 360,
+                                        durationMillis = NAV_FADE_DURATION_MILLIS,
                                         easing = LinearOutSlowInEasing
                                     )
                                 )) togetherWith (scaleOut(
                                     targetScale = 0.8f,
                                     animationSpec = tween(
-                                        durationMillis = 480,
+                                        durationMillis = NAV_SLIDE_DURATION_MILLIS,
                                         easing = FastOutSlowInEasing
                                     )
-                                ) + fadeOut(animationSpec = tween(durationMillis = 360)))
+                                ) + fadeOut(animationSpec = tween(durationMillis = NAV_FADE_DURATION_MILLIS)))
                             },
                             predictivePopTransitionSpec = { _ ->
                                 (slideIntoContainer(
@@ -718,23 +718,26 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                                 capsuleAnchorPreview = capsuleAnchorPreview,
                             )
                         )
-                        // 主页开启悬浮底栏时使用圆形胶囊,其余页面使用全局可拖拽胶囊。
-                        // 隐藏的胶囊不再上报形变锚点。
-                        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) },
-                            )
+                    }
+                    // 全局胶囊与导航容器并列,始终画在阅读等 Nav3 叠层之上。
+                    // 主页开启悬浮底栏时仍使用主页自己的圆形胶囊。
+                    CompositionLocalProvider(
+                        // 收起完成后不再用残留进度给可点击胶囊加透明层。
+                        LocalReadAloudMorph provides readAloudMorph.takeIf {
+                            !useHomeCapsule && (playerVisible || readAloudMorph.progress.isRunning)
                         }
+                    ) {
+                        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(

+ 9 - 10
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -228,13 +228,8 @@ private fun readerEntryMetadata(predictiveBackEnabled: Boolean) =
         }
     }
 
-/**
- * 以底部弹层呈现的目的地(听书播放页、有声书播放页)。
- *
- * 上一站保持组合在弹层之下(真实背景可见),目的地自身不参与转场:
- * 进出动画完全由 `AppModalBottomSheet` 负责,导航层只负责栈。
- */
-private fun sheetEntryMetadata(): Map<String, Any> =
+/** Keep parent overlays composed while NavDisplay leaves animation to the search scene or player host. */
+private fun modalOverlayEntryMetadata(): Map<String, Any> =
     ModalOverlaySceneStrategy.modalOverlay() + metadata {
         put(NavDisplay.TransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
         put(NavDisplay.PopTransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
@@ -955,7 +950,7 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
-    entry<MainRouteReadAloudPlayer>(metadata = sheetEntryMetadata()) {
+    entry<MainRouteReadAloudPlayer>(metadata = modalOverlayEntryMetadata()) {
         LaunchedEffect(Unit) {
             ReadAloudPlayerOverlayBus.request(PlaybackCapsuleState(source = PlaybackCapsuleSource.ReadAloud))
             if (backStack.size > 1) onNavigateBack() else backStack[0] = MainRouteHome
@@ -963,7 +958,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     // 兼容已保存的导航栈。新入口在 MainActivity 直接打开同窗口播放浮层。
-    entry<MainRouteAudioPlay>(metadata = sheetEntryMetadata()) { route ->
+    entry<MainRouteAudioPlay>(metadata = modalOverlayEntryMetadata()) { route ->
         LaunchedEffect(route) {
             ReadAloudPlayerOverlayBus.request(
                 PlaybackCapsuleState(
@@ -976,13 +971,17 @@ fun MainActivity.mainEntryProvider(
         }
     }
 
-    entry<MainRouteSearchContent> { route ->
+    entry<MainRouteSearchContent>(
+        metadata = modalOverlayEntryMetadata() + ModalOverlaySceneStrategy.searchSlide()
+    ) { route ->
         val viewModel = koinViewModel<SearchContentViewModel>(
             key = "SearchContent:${route.bookUrl}",
             parameters = { parametersOf(route) }
         )
         SearchContentRouteScreen(
             viewModel = viewModel,
+            isTopRoute = backStack.lastOrNull() == route,
+            predictiveBackEnabled = configuration.appShell.predictiveBackEnabled,
             autoFocus = route.autoFocus,
             onBack = { onNavigateBack() },
         )

+ 185 - 3
app/src/main/java/io/legado/app/ui/main/ModalOverlaySceneStrategy.kt

@@ -1,8 +1,23 @@
 package io.legado.app.ui.main
 
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.zIndex
 import androidx.navigation3.runtime.NavEntry
 import androidx.navigation3.runtime.NavKey
@@ -13,9 +28,15 @@ import androidx.navigation3.scene.OverlayScene
 import androidx.navigation3.scene.Scene
 import androidx.navigation3.scene.SceneStrategy
 import androidx.navigation3.scene.SceneStrategyScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
 
-/** Keeps the previous destination composed while an entry renders its own modal window. */
+internal const val NAV_SLIDE_DURATION_MILLIS = 480
+internal const val NAV_FADE_DURATION_MILLIS = 360
+
+/** Keeps parent destinations composed beneath a Nav3 overlay scene. */
 class ModalOverlaySceneStrategy : SceneStrategy<NavKey> {
+    private val searchAnimations = SearchOverlayAnimations()
 
     override fun SceneStrategyScope<NavKey>.calculateScene(
         entries: List<NavEntry<NavKey>>,
@@ -24,25 +45,37 @@ class ModalOverlaySceneStrategy : SceneStrategy<NavKey> {
         entry.metadata[MetadataKey] ?: return null
         val previousEntries = entries.dropLast(1)
         if (previousEntries.isEmpty()) return null
+        if (entry.metadata[SearchSlideKey] != null) {
+            return SearchOverlayScene(
+                entry = entry,
+                previousEntries = previousEntries,
+                animations = searchAnimations,
+            )
+        }
         return ModalOverlayScene(
             entry = entry,
             previousEntries = previousEntries,
+            animations = searchAnimations,
         )
     }
 
     companion object {
         private object MetadataKey : NavMetadataKey<Unit>
+        private object SearchSlideKey : NavMetadataKey<Unit>
 
         fun modalOverlay(): Map<String, Any> = metadata { put(MetadataKey, Unit) }
+        fun searchSlide(): Map<String, Any> = metadata { put(SearchSlideKey, Unit) }
     }
 }
 
 private data class ModalOverlayScene(
     private val entry: NavEntry<NavKey>,
     override val previousEntries: List<NavEntry<NavKey>>,
+    private val animations: SearchOverlayAnimations,
 ) : OverlayScene<NavKey> {
     override val key: Any = entry.contentKey
     override val entries: List<NavEntry<NavKey>> = listOf(entry)
+    override val metadata: Map<String, Any> = entry.metadata
 
     // NavDisplay recursively resolves the scene underneath an overlay. Passing only the
     // last entry turns a nested overlay (book info under a reader) into a SinglePane root,
@@ -52,8 +85,157 @@ private data class ModalOverlayScene(
         // NavDisplay retains existing overlays and appends newly opened ones. Their
         // composition order can therefore differ from stack order for nested overlays.
         // Draw and hit-test by stack depth so a reader always sits above book info.
-        Box(Modifier.zIndex(previousEntries.size.toFloat())) {
-            entry.Content()
+        val search = animations.covering(key)
+        Box(
+            Modifier
+                .zIndex(previousEntries.size.toFloat())
+                // 搜索覆盖阅读页时,阅读页淡出到主题背景,而不是露出更底层的主页。
+                .then(
+                    if (search != null) {
+                        Modifier
+                            .fillMaxSize()
+                            .background(MaterialTheme.colorScheme.background)
+                    } else Modifier
+                )
+        ) {
+            Box(Modifier.graphicsLayer {
+                translationX = -size.width / 4f * (search?.progress?.value ?: 0f)
+                alpha = search?.underlayOpacity?.value ?: 1f
+            }) {
+                entry.Content()
+            }
+        }
+    }
+}
+
+/** Keeps the reader composed while Nav3 owns the search panel's entry and removal lifetime. */
+private data class SearchOverlayScene(
+    private val entry: NavEntry<NavKey>,
+    override val previousEntries: List<NavEntry<NavKey>>,
+    private val animations: SearchOverlayAnimations,
+) : OverlayScene<NavKey> {
+    override val key: Any = entry.contentKey
+    override val entries: List<NavEntry<NavKey>> = listOf(entry)
+    override val overlaidEntries: List<NavEntry<NavKey>> = previousEntries
+    override val metadata: Map<String, Any> = entry.metadata
+    private val animation = animations.state(key, previousEntries.last().contentKey)
+
+    override val content: @Composable () -> Unit = {
+        LaunchedEffect(key) {
+            coroutineScope {
+                launch {
+                    animation.progress.animateTo(
+                        1f,
+                        tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
+                    )
+                }
+                launch {
+                    animation.opacity.animateTo(
+                        1f,
+                        tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
+                    )
+                }
+                launch {
+                    animation.underlayOpacity.animateTo(
+                        0f,
+                        tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
+                    )
+                }
+            }
+        }
+        CompositionLocalProvider(LocalSearchOverlayAnimation provides animation) {
+            Box(
+                Modifier
+                    .fillMaxSize()
+                    .zIndex(previousEntries.size.toFloat())
+                    .graphicsLayer {
+                        translationX =
+                            if (animation.removing) 0f else size.width * (1f - animation.progress.value)
+                        scaleX =
+                            if (animation.removing) 0.8f + 0.2f * animation.progress.value else 1f
+                        scaleY = scaleX
+                        alpha = animation.opacity.value
+                    }
+            ) {
+                entry.Content()
+            }
+        }
+    }
+
+    override suspend fun onRemove() {
+        try {
+            animation.removing = true
+            coroutineScope {
+                launch {
+                    animation.progress.animateTo(
+                        0f,
+                        tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
+                    )
+                }
+                launch { animation.opacity.animateTo(0f, tween(NAV_FADE_DURATION_MILLIS)) }
+                launch {
+                    animation.underlayOpacity.animateTo(
+                        1f,
+                        tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
+                    )
+                }
+            }
+        } finally {
+            animations.release(key, animation)
+        }
+    }
+}
+
+internal class SearchOverlayAnimation(val underlayKey: Any) {
+    val progress = Animatable(0f)
+    val opacity = Animatable(0f)
+    val underlayOpacity = Animatable(1f)
+    var removing by mutableStateOf(false)
+
+    suspend fun previewBack(progress: Float) {
+        removing = true
+        // 与 NavDisplay 的 predictivePopTransitionSpec 使用同一 easing。
+        val fraction = progress.coerceIn(0f, 1f)
+        val remaining = 1f - FastOutSlowInEasing.transform(fraction)
+        this.progress.snapTo(remaining)
+        opacity.snapTo(remaining)
+        underlayOpacity.snapTo(LinearOutSlowInEasing.transform(fraction))
+    }
+
+    suspend fun cancelPreview() {
+        coroutineScope {
+            launch {
+                progress.animateTo(
+                    1f,
+                    tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
+                )
+            }
+            launch { opacity.animateTo(1f, tween(NAV_FADE_DURATION_MILLIS)) }
+            launch {
+                underlayOpacity.animateTo(
+                    0f,
+                    tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
+                )
+            }
         }
+        removing = false
+    }
+}
+
+internal val LocalSearchOverlayAnimation =
+    staticCompositionLocalOf<SearchOverlayAnimation?> { null }
+
+private class SearchOverlayAnimations {
+    // 旧阅读场景可能被 NavDisplay 复用;搜索入栈时必须使它重新读取覆盖动画。
+    private val byKey = mutableStateMapOf<Any, SearchOverlayAnimation>()
+
+    fun state(key: Any, underlayKey: Any): SearchOverlayAnimation =
+        byKey.getOrPut(key) { SearchOverlayAnimation(underlayKey) }
+
+    fun covering(key: Any): SearchOverlayAnimation? =
+        byKey.values.lastOrNull { it.underlayKey == key }
+
+    fun release(key: Any, animation: SearchOverlayAnimation) {
+        if (byKey[key] === animation) byKey.remove(key)
     }
 }

+ 105 - 0
app/src/test/java/io/legado/app/model/ReadAloudStopServiceTest.kt

@@ -0,0 +1,105 @@
+package io.legado.app.model
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.ContextWrapper
+import android.content.Intent
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.service.BaseReadAloudService
+import io.legado.app.service.TTSReadAloudService
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+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.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadAloudStopServiceTest {
+    private lateinit var previousServiceClass: Class<*>
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+        stopKoin()
+        startKoin {
+            modules(module {
+                single<ReadAloudSettingsGateway> { TestReadAloudSettingsGateway() }
+            })
+        }
+        val serviceClassField = ReadAloud::class.java.getDeclaredField("aloudClass").apply {
+            isAccessible = true
+        }
+        previousServiceClass = serviceClassField.get(ReadAloud) as Class<*>
+        serviceClassField.set(ReadAloud, TTSReadAloudService::class.java)
+        setServiceFlag("isRun", true)
+        setServiceFlag("stopRequested", false)
+    }
+
+    @After
+    fun tearDown() {
+        ReadAloud::class.java.getDeclaredField("aloudClass").apply {
+            isAccessible = true
+            set(ReadAloud, previousServiceClass)
+        }
+        setServiceFlag("isRun", false)
+        setServiceFlag("stopRequested", false)
+        stopKoin()
+    }
+
+    @Test
+    fun stopFromExternalCapsuleDoesNotStartAnotherForegroundService() {
+        val context = RecordingContext(RuntimeEnvironment.getApplication())
+
+        ReadAloud.stop(context)
+
+        assertEquals(
+            TTSReadAloudService::class.java.name,
+            context.stoppedService?.component?.className
+        )
+        assertEquals(0, context.foregroundStartCount)
+        assertFalse(BaseReadAloudService.isRun)
+        assertFalse(BaseReadAloudService.requestStop())
+    }
+
+    private fun setServiceFlag(name: String, value: Boolean) {
+        BaseReadAloudService::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            setBoolean(null, value)
+        }
+    }
+
+    private class RecordingContext(base: Application) : ContextWrapper(base) {
+        var stoppedService: Intent? = null
+        var foregroundStartCount = 0
+
+        override fun stopService(name: Intent): Boolean {
+            stoppedService = name
+            return true
+        }
+
+        override fun startForegroundService(service: Intent): ComponentName? {
+            foregroundStartCount++
+            return service.component
+        }
+    }
+
+    private class TestReadAloudSettingsGateway : ReadAloudSettingsGateway {
+        override val settings = MutableStateFlow(ReadAloudSettings())
+        override val currentSettings get() = settings.value
+
+        override suspend fun update(transform: (ReadAloudSettings) -> ReadAloudSettings) {
+            settings.value = transform(settings.value)
+        }
+    }
+}

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

@@ -8,6 +8,7 @@ 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.model.ReadBook
 import io.legado.app.service.AudioPlayService
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.playback.PlaybackCapsuleGatewayAdapter
@@ -40,6 +41,7 @@ class PlaybackCapsuleGatewayAdapterTest {
     private var previousChapter: BookChapter? = null
     private var previousChapterIndex = 0
     private var previousStatus = Status.STOP
+    private var previousReadBook: Book? = null
 
     @Before
     fun setUp() {
@@ -49,6 +51,7 @@ class PlaybackCapsuleGatewayAdapterTest {
         previousChapterIndex = AudioPlay.durChapterIndex
         previousBook = AudioPlay.book
         previousStatus = AudioPlay.status
+        previousReadBook = ReadBook.book
         assertTrue(!AudioPlayService.isRun)
         assertTrue(!BaseReadAloudService.isRun)
         AudioPlay.book = Book(bookUrl = "audio-a", name = "有声书")
@@ -61,6 +64,11 @@ class PlaybackCapsuleGatewayAdapterTest {
         AudioPlay.durChapterIndex = previousChapterIndex
         AudioPlay.book = previousBook
         AudioPlay.status = previousStatus
+        setReadBook(previousReadBook)
+        BaseReadAloudService::class.java.getDeclaredField("isRun").apply {
+            isAccessible = true
+            setBoolean(null, false)
+        }
         Dispatchers.resetMain()
     }
 
@@ -68,6 +76,28 @@ class PlaybackCapsuleGatewayAdapterTest {
         RuntimeEnvironment.getApplication(), ReadAloudSessionStore(),
     )
 
+    private fun setReadBook(book: Book?) {
+        ReadBook::class.java.getDeclaredField("book").apply {
+            isAccessible = true
+            set(null, book)
+        }
+    }
+
+    @Test
+    fun runningReadAloudServiceKeepsCapsuleWhenAvailabilitySignalIsStale() = runTest(dispatcher) {
+        setReadBook(Book(bookUrl = "read-a", name = "正在朗读"))
+        BaseReadAloudService::class.java.getDeclaredField("isRun").apply {
+            isAccessible = true
+            setBoolean(null, true)
+        }
+        val gateway = gateway()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { gateway.state.collect { } }
+        gateway.setSessionAvailable(PlaybackCapsuleSource.ReadAloud, false)
+        runCurrent()
+        assertEquals(PlaybackCapsuleSource.ReadAloud, gateway.state.value.source)
+        assertEquals("read-a", gateway.state.value.bookUrl)
+    }
+
     @Test
     fun loadedBookWithoutServiceRemainsAResumablePausedCapsule() = runTest(dispatcher) {
         val gateway = gateway()

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

@@ -166,6 +166,13 @@ class PlayerMorphFramesTest {
         assertEquals(1f, computeMorphPanelAlpha(1f), 0f)
     }
 
+    @Test
+    fun `capsule cover fades continuously during the final morph segment`() {
+        assertEquals(1f, computeCapsuleCoverAlpha(0f), 0f)
+        assertEquals(0.5f, computeCapsuleCoverAlpha(0.075f), 0.001f)
+        assertEquals(0f, computeCapsuleCoverAlpha(0.15f), 0f)
+    }
+
     @Test
     fun `veil transitions smoothly in mid to late phase`() {
         assertEquals(0f, computeMorphVeil(0.20f), 0.001f)

+ 65 - 2
app/src/test/java/io/legado/app/ui/main/ModalOverlaySceneStrategyTest.kt

@@ -1,10 +1,13 @@
 package io.legado.app.ui.main
 
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
 import androidx.navigation3.runtime.NavEntry
 import androidx.navigation3.runtime.NavKey
 import androidx.navigation3.scene.OverlayScene
 import androidx.navigation3.scene.Scene
 import androidx.navigation3.scene.SceneStrategyScope
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertNull
@@ -14,6 +17,25 @@ class ModalOverlaySceneStrategyTest {
     private val strategy = ModalOverlaySceneStrategy()
     private val scope = SceneStrategyScope<NavKey>()
 
+    @Test
+    fun `search predictive back updates both overlay and reader from one progress`() = runBlocking {
+        val animation = SearchOverlayAnimation(underlayKey = "reader")
+        animation.progress.snapTo(1f)
+        animation.opacity.snapTo(1f)
+        animation.underlayOpacity.snapTo(0f)
+
+        animation.previewBack(0.4f)
+
+        assertEquals(true, animation.removing)
+        assertEquals(1f - FastOutSlowInEasing.transform(0.4f), animation.progress.value, 0.0001f)
+        assertEquals(animation.progress.value, animation.opacity.value, 0.0001f)
+        assertEquals(
+            LinearOutSlowInEasing.transform(0.4f),
+            animation.underlayOpacity.value,
+            0.0001f
+        )
+    }
+
     @Test
     fun `reader keeps the same book info overlay and home parent`() {
         val home = entry(MainRouteHome)
@@ -28,6 +50,7 @@ class ModalOverlaySceneStrategyTest {
         assertNotNull("Book info must remain an overlay while reading", infoUnderReader)
         assertEquals(infoBeforeReading, infoUnderReader)
         assertEquals(listOf(home), (infoUnderReader as OverlayScene<NavKey>).previousEntries)
+        assertEquals(reader.metadata, readerScene.metadata)
 
         val infoAfterReading = calculate(readerScene.previousEntries)
         assertEquals(infoBeforeReading, infoAfterReading)
@@ -41,12 +64,52 @@ class ModalOverlaySceneStrategyTest {
         assertNull(calculate(listOf(entry(MainRouteHome))))
     }
 
+    /**
+     * 压在阅读界面之上的目的地(全文搜索正文)必须自己登记成叠层。
+     *
+     * 只要栈顶没有叠层元数据,本策略就返回 null,`NavDisplay` 会退回到
+     * `SinglePaneSceneStrategy`:阅读界面所在叠层被整帧丢弃、`overlayScenes` 清空,
+     * `previousScenes` 也不再有可回退的上一站。表现就是返回阅读界面时重播入场动画,
+     * 且返回手势不再被阅读界面或导航层接管,直接结束 Activity。
+     */
+    @Test
+    fun `destination above a reader overlay must itself declare overlay metadata`() {
+        val home = entry(MainRouteHome)
+        val reader = entry(MainRouteReadBook(bookUrl = "book-url"), overlay = true)
+        val searchOverlay = entry(
+            MainRouteSearchContent(bookUrl = "book-url"), overlay = true, searchSlide = true,
+        )
+        val searchPlain = entry(MainRouteSearchContent(bookUrl = "book-url"))
+
+        val searchScene = calculate(listOf(home, reader, searchOverlay)) as OverlayScene<NavKey>
+        val plainScene = calculate(listOf(home, reader, searchPlain))
+        // 搜索正文这一叠层之下必须仍然是 [主页, 阅读界面]:阅读界面保持组合,
+        // 返回时 `previousScenes` 也有可回退的一站。
+        assertEquals(
+            searchScene.overlaidEntries.map { it.contentKey },
+            listOf(home, reader).map { it.contentKey },
+        )
+        assertEquals(searchScene, calculate(listOf(home, reader, searchOverlay)))
+        assertEquals(searchOverlay.metadata, searchScene.metadata)
+
+        // 反例:普通单栏目的地会让阅读界面这一层没有场景承载者。
+        assertNull(
+            "普通单栏目的地压在阅读界面上会丢弃阅读界面叠层",
+            plainScene,
+        )
+    }
+
     private fun calculate(entries: List<NavEntry<NavKey>>): Scene<NavKey>? =
         with(strategy) { scope.calculateScene(entries) }
 
-    private fun entry(key: NavKey, overlay: Boolean = false): NavEntry<NavKey> =
+    private fun entry(
+        key: NavKey,
+        overlay: Boolean = false,
+        searchSlide: Boolean = false,
+    ): NavEntry<NavKey> =
         NavEntry(
             key = key,
-            metadata = if (overlay) ModalOverlaySceneStrategy.modalOverlay() else emptyMap(),
+            metadata = (if (overlay) ModalOverlaySceneStrategy.modalOverlay() else emptyMap()) +
+                    (if (searchSlide) ModalOverlaySceneStrategy.searchSlide() else emptyMap()),
         ) {}
 }