فهرست منبع

Merge remote-tracking branch 'origin/main' into codex/pr-1962

# Conflicts:
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
HapeLee 3 ماه پیش
والد
کامیت
60544816d3
25فایلهای تغییر یافته به همراه495 افزوده شده و 308 حذف شده
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 3 0
      app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt
  3. 3 0
      app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt
  4. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt
  5. 1 0
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  6. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  7. 32 2
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  8. 1 3
      app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
  9. 70 9
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt
  10. 1 0
      app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt
  11. 4 3
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  12. 269 280
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  13. 10 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt
  14. 0 2
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  15. 1 2
      app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
  16. 7 2
      app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
  17. 13 2
      app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt
  18. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  19. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  20. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  21. 1 0
      app/src/main/res/values/strings.xml
  22. 25 0
      app/src/test/java/io/legado/app/BookSearchScopeTest.kt
  23. 5 2
      app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt
  24. 10 0
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt
  25. 33 0
      app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -57,6 +57,7 @@ object PreferKey {
     const val bookshelfLayoutModePortrait = "bookshelfLayoutPortrait"
     const val bookshelfLayoutModeLandscape = "bookshelf_layout_landscape"
     const val bookshelfLayoutCompact = "bookshelfLayoutCompact"
+    const val bookshelfListIntroBelowContent = "bookshelfListIntroBelowContent"
     const val bookshelfShowDivider = "bookshelfShowDivider"
     const val bookshelfGridLayout = "bookshelfGridLayout"
     const val bookshelfSort = "bookshelfSort"

+ 3 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt

@@ -63,6 +63,8 @@ internal fun Preferences.toBookshelfSettings() = BookshelfSettings(
     bookshelfFolderLayoutListLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutListLandscape) ?: 1,
     bookshelfGridLayout = compatDsInt(PreferKey.bookshelfGridLayout) ?: 0,
     bookshelfLayoutCompact = compatDsBoolean(PreferKey.bookshelfLayoutCompact) ?: false,
+    bookshelfListIntroBelowContent = compatDsBoolean(PreferKey.bookshelfListIntroBelowContent)
+        ?: false,
     bookshelfShowDivider = compatDsBoolean(PreferKey.bookshelfShowDivider) ?: true,
     bookshelfTitleSmallFont = compatDsBoolean(PreferKey.bookshelfTitleSmallFont) ?: false,
     bookshelfTitleCenter = compatDsBoolean(PreferKey.bookshelfTitleCenter) ?: true,
@@ -112,6 +114,7 @@ internal fun BookshelfSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
     PreferKey.bookshelfTitleCenter to bookshelfTitleCenter,

+ 3 - 0
app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt

@@ -53,6 +53,9 @@ data class BookSearchScope(val raw: String) {
 
     companion object {
 
+        fun encodeSource(name: String, url: String): String =
+            encodeSources(listOf(ScopeSourceItem(name, url)))
+
         fun encodeGroups(groups: List<String>): String {
             val selected = groups.filter { it.isNotBlank() }
             return if (selected.isEmpty()) {

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

@@ -35,6 +35,7 @@ data class BookshelfSettings(
     val bookshelfFolderLayoutListLandscape: Int = 1,
     val bookshelfGridLayout: Int = 0,
     val bookshelfLayoutCompact: Boolean = false,
+    val bookshelfListIntroBelowContent: Boolean = false,
     val bookshelfShowDivider: Boolean = true,
     val bookshelfTitleSmallFont: Boolean = false,
     val bookshelfTitleCenter: Boolean = true,

+ 1 - 0
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -306,6 +306,7 @@ object BackupConfig {
         PreferKey.bookshelfLayoutModePortrait,
         PreferKey.bookshelfLayoutModeLandscape,
         PreferKey.bookshelfLayoutCompact,
+        PreferKey.bookshelfListIntroBelowContent,
         PreferKey.bookshelfShowDivider,
         PreferKey.bookshelfGridLayout,
         PreferKey.bookshelfSort,

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

@@ -37,7 +37,7 @@ class ReadBookmarkDelegate(
                 bookAuthor = book.author,
                 chapterIndex = chapter.chapter.index,
                 chapterName = chapter.title,
-                chapterPos = ReadBook.durPageIndex,
+                chapterPos = ReadBook.durChapterPos,
                 bookText = page.text,
                 content = "",
             )

+ 32 - 2
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -11,8 +11,11 @@ import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
+import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.view.ViewCompat
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -118,6 +121,7 @@ class ReadView(
     private val upProgressThrottle = throttle(200) { post { upProgress() } }
     val autoPager = AutoPager(this)
     val isAutoPage get() = autoPager.isRunning
+    private var accessibilityPageText = ""
 
     init {
         if (!isInEditMode) {
@@ -130,10 +134,16 @@ class ReadView(
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
+        // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
+        // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
+        listOf(prevPage, curPage, nextPage).forEach {
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+        }
         descendantFocusability = FOCUS_BLOCK_DESCENDANTS
         importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
         isClickable = true
         isFocusable = true
+        ViewCompat.setScreenReaderFocusable(this, true)
         prevPage.invisible()
         nextPage.invisible()
         curPage.markAsMainView()
@@ -183,6 +193,9 @@ class ReadView(
         return true
     }
 
+    // Canvas 正文没有子级语义,触摸探索必须命中本页级节点。
+    override fun onInterceptHoverEvent(event: MotionEvent): Boolean = true
+
     /**
      * 触摸事件
      */
@@ -289,7 +302,9 @@ class ReadView(
 
     override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
         super.onInitializeAccessibilityNodeInfo(info)
-        info.className = ReadView::class.java.name
+        // The Canvas-rendered page is exposed as one stable text node.
+        info.className = TextView::class.java.name
+        info.text = accessibilityPageText
         info.isScrollable = true
         info.addAction(
             AccessibilityNodeInfo.AccessibilityAction(
@@ -311,6 +326,13 @@ class ReadView(
         )
     }
 
+    override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
+        super.onInitializeAccessibilityEvent(event)
+        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
+            event.contentChangeTypes = AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT
+        }
+    }
+
     override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
         return when (action) {
             AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
@@ -628,7 +650,7 @@ class ReadView(
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
             val text = pageFactory.curPage.text
-            contentDescription = text
+            updateAccessibilityPageText(text)
             if (BuildConfig.DEBUG) {
                 // Device-independent readiness/page-change signal for the debug
                 // scenario runner (uiautomator does not expose ReadView on all
@@ -661,6 +683,14 @@ class ReadView(
         callBack.screenOffTimerStart()
     }
 
+    private fun updateAccessibilityPageText(text: String) {
+        if (accessibilityPageText == text) return
+        accessibilityPageText = text
+        if (isAttachedToWindow) {
+            sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)
+        }
+    }
+
     private fun upProgress() {
         curPage.setProgress(pageFactory.curPage)
     }

+ 1 - 3
app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt

@@ -160,9 +160,7 @@ data class SearchScope(private var scope: String) {
         }
 
         private fun encodeSourceScope(name: String, url: String): String {
-            return BookSearchScope.encodeSources(
-                listOf(ScopeSourceItem(sanitizeSourceName(name), url))
-            )
+            return BookSearchScope.encodeSource(sanitizeSourceName(name), url)
         }
 
         private fun encodeSourceScope(sources: List<BookSourcePart>): String {

+ 70 - 9
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -4,7 +4,6 @@ import android.content.ClipData
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -13,17 +12,20 @@ import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.selectable
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.minimumInteractiveComponentSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -37,6 +39,10 @@ import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
@@ -82,7 +88,7 @@ fun BookSourceRouteScreen(
     onAddSource: () -> Unit,
     onEditSource: (String) -> Unit,
     onLoginSource: (String) -> Unit,
-    onSearchSource: (String) -> Unit,
+    onSearchSource: (String, String) -> Unit,
     onDebugSource: (String) -> Unit,
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -108,7 +114,7 @@ fun BookSourceScreen(
     onAddSource: () -> Unit,
     onEditSource: (String) -> Unit,
     onLoginSource: (String) -> Unit,
-    onSearchSource: (String) -> Unit,
+    onSearchSource: (String, String) -> Unit,
     onDebugSource: (String) -> Unit,
     effects: Flow<BookSourceEffect>,
 ) {
@@ -153,6 +159,7 @@ fun BookSourceScreen(
     }
     val snackbarHostState = remember { SnackbarHostState() }
     val clipboardManager = LocalClipboard.current
+    val cancelLabel = stringResource(R.string.cancel)
     LaunchedEffect(Unit) {
         effects.collectLatest { effect ->
             when (effect) {
@@ -175,7 +182,7 @@ fun BookSourceScreen(
         val progress = state.checkProgress ?: return@LaunchedEffect
         val result = snackbarHostState.showSnackbar(
             message = progress,
-            actionLabel = context.getString(R.string.cancel),
+            actionLabel = cancelLabel,
             withDismissAction = false,
             duration = androidx.compose.material3.SnackbarDuration.Indefinite,
         )
@@ -233,6 +240,7 @@ fun BookSourceScreen(
         topBarActions = {
             Box {
                 SmallPlainButton(
+                    modifier = Modifier.minimumInteractiveComponentSize(),
                     icon = AppIcons.MoreVert,
                     contentDescription = stringResource(R.string.menu),
                     onClick = { showImportOptions = true },
@@ -247,16 +255,25 @@ fun BookSourceScreen(
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_original_name),
                         isSelected = importSuccess?.keepOriginalName == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalName == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepName(importSuccess?.keepOriginalName != true)) },
                     )
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_group),
                         isSelected = importSuccess?.keepOriginalGroup == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalGroup == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepGroup(importSuccess?.keepOriginalGroup != true)) },
                     )
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_enable),
                         isSelected = importSuccess?.keepOriginalEnable == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalEnable == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepEnable(importSuccess?.keepOriginalEnable != true)) },
                     )
                     RoundDropdownMenuItem(stringResource(R.string.diy_source_group), onClick = {
@@ -489,6 +506,7 @@ fun BookSourceScreen(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.group_sources_by_domain),
                 isSelected = state.groupByDomain,
+                modifier = Modifier.semantics { this.selected = state.groupByDomain },
                 onClick = { dismiss(); onIntent(BookSourceIntent.ToggleGroupByDomain) },
             )
             PillDivider()
@@ -514,6 +532,7 @@ fun BookSourceScreen(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.sort_desc),
                 isSelected = !state.sortAscending,
+                modifier = Modifier.semantics { this.selected = !state.sortAscending },
                 onClick = { dismiss(); onIntent(BookSourceIntent.ToggleSortDirection) },
             )
         }
@@ -537,11 +556,38 @@ fun BookSourceScreen(
                                 color = LegadoTheme.colorScheme.primary,
                                 modifier = Modifier
                                     .fillMaxWidth()
+                                    .semantics { heading() }
                                     .padding(horizontal = 16.dp, vertical = 8.dp),
                             )
                         }
                     }
                     item(key = item.id, contentType = "book-source") {
+                        val canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain
+                        val enabledState = stringResource(
+                            if (item.enabled) R.string.enabled else R.string.disabled
+                        )
+                        val discoveryState = if (item.hasExploreUrl) {
+                            stringResource(
+                                if (item.enabledExplore) R.string.enabled_explore
+                                else R.string.disabled_explore
+                            )
+                        } else {
+                            null
+                        }
+                        val reorderHint = if (canReorder) {
+                            stringResource(R.string.a11y_long_press_reorder)
+                        } else {
+                            null
+                        }
+                        val itemDescription = listOfNotNull(
+                            item.name,
+                            item.domain.takeUnless { it == "#" },
+                            item.group,
+                            enabledState,
+                            discoveryState,
+                            item.checkMessage,
+                            reorderHint,
+                        ).joinToString()
                         ReorderableSelectionItem(
                             state = reorderState,
                             key = item.id,
@@ -567,7 +613,7 @@ fun BookSourceScreen(
                             ).joinToString(" · ").ifBlank { null },
                             isEnabled = item.enabled,
                             isSelected = item.id in selectedIds,
-                            canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                            canReorder = canReorder,
                             inSelectionMode = selectedIds.isNotEmpty(),
                             onToggleSelection = { onIntent(BookSourceIntent.ToggleSelection(item.id)) },
                             onEnabledChange = {
@@ -578,16 +624,22 @@ fun BookSourceScreen(
                                     )
                                 )
                             },
-                            onClickEdit = { onEditSource(item.id) },
+                            contentDescription = itemDescription,
                             trailingAction = {
+                                SmallPlainButton(
+                                    modifier = Modifier.minimumInteractiveComponentSize(),
+                                    icon = AppIcons.Edit,
+                                    contentDescription = stringResource(R.string.edit),
+                                    onClick = { onEditSource(item.id) },
+                                )
                                 BookSourceItemMenu(
                                     item = item,
-                                    canMoveToEdge = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                                    canMoveToEdge = canReorder,
                                     onMoveToEdge = { toTop ->
                                         onIntent(BookSourceIntent.MoveToEdge(setOf(item.id), toTop))
                                     },
                                     onLogin = { onLoginSource(item.id) },
-                                    onSearch = { onSearchSource(item.id) },
+                                    onSearch = { onSearchSource(item.name, item.id) },
                                     onDebug = { onDebugSource(item.id) },
                                     onDelete = { deleteIds = setOf(item.id) },
                                     onSetExploreEnabled = { enabled ->
@@ -697,6 +749,7 @@ private fun CheckBookSourceSheet(
 
 private enum class CheckSheet { Run, Settings }
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 private fun BookSourceItemMenu(
     item: BookSourceItemUi,
@@ -711,6 +764,7 @@ private fun BookSourceItemMenu(
     var expanded by remember { mutableStateOf(false) }
     Box {
         SmallPlainButton(
+            modifier = Modifier.minimumInteractiveComponentSize(),
             icon = AppIcons.MoreVert,
             contentDescription = stringResource(R.string.menu),
             onClick = { expanded = true },
@@ -765,6 +819,7 @@ private fun SortMenuItem(
 ) = RoundDropdownMenuItem(
     text = stringResource(textRes),
     isSelected = state.sort == sort,
+    modifier = Modifier.semantics { this.selected = state.sort == sort },
     onClick = { dismiss(); onIntent(BookSourceIntent.SetSort(sort)) },
 )
 
@@ -848,6 +903,7 @@ private fun GroupFilterSectionTitle(text: String) {
         color = LegadoTheme.colorScheme.onSurfaceVariant,
         modifier = Modifier
             .fillMaxWidth()
+            .semantics { heading() }
             .padding(horizontal = 4.dp, vertical = 6.dp),
     )
 }
@@ -864,12 +920,17 @@ private fun GroupFilterItem(
         contentAlignment = Alignment.Center,
         modifier = Modifier
             .fillMaxWidth()
+            .heightIn(min = 48.dp)
             .clip(shape = RoundedCornerShape(12.dp))
             .background(
                 color = if (selected) LegadoTheme.colorScheme.primaryContainer
                 else LegadoTheme.colorScheme.onSheetContent
             )
-            .clickable { onSelect(value) }
+            .selectable(
+                selected = selected,
+                role = Role.RadioButton,
+                onClick = { onSelect(value) },
+            )
             .padding(horizontal = 12.dp, vertical = 8.dp),
     ) {
         AppText(

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt

@@ -37,6 +37,7 @@ object BookshelfConfig {
     val bookshelfFolderLayoutListLandscape get() = settings.bookshelfFolderLayoutListLandscape
     val bookshelfGridLayout get() = settings.bookshelfGridLayout
     val bookshelfLayoutCompact get() = settings.bookshelfLayoutCompact
+    val bookshelfListIntroBelowContent get() = settings.bookshelfListIntroBelowContent
     val bookshelfShowDivider get() = settings.bookshelfShowDivider
     val bookshelfTitleSmallFont get() = settings.bookshelfTitleSmallFont
     val bookshelfTitleCenter get() = settings.bookshelfTitleCenter

+ 4 - 3
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -29,6 +29,7 @@ import androidx.navigation3.runtime.entryProvider
 import androidx.navigation3.ui.LocalNavAnimatedContentScope
 import androidx.navigation3.ui.NavDisplay
 import io.legado.app.R
+import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.settings.AppUiConfiguration
 import io.legado.app.model.Download
 import io.legado.app.ui.about.AboutEffect
@@ -77,7 +78,6 @@ import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsScreen
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
 import io.legado.app.ui.book.search.SearchIntent
 import io.legado.app.ui.book.search.SearchRouteScreen
-import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentRouteScreen
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
@@ -162,8 +162,9 @@ fun MainActivity.mainEntryProvider(
             onLoginSource = {
                 onNavigateToRoute(MainRouteSourceLogin(SourceLoginType.BookSource, it))
             },
-            onSearchSource = { sourceUrl ->
-                onNavigateToRoute(MainRouteSearch(null, SearchScope(sourceUrl).toString()))
+            onSearchSource = { sourceName, sourceUrl ->
+                val scopeRaw = BookSearchScope.encodeSource(sourceName, sourceUrl)
+                onNavigateToRoute(MainRouteSearch(null, scopeRaw))
             },
             onDebugSource = { sourceUrl ->
                 onNavigateToRoute(MainRouteBookSourceDebug(sourceUrl))

+ 269 - 280
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -55,6 +55,7 @@ import io.legado.app.constant.BookType
 import io.legado.app.domain.model.settings.BookshelfSettings
 import io.legado.app.ui.config.themeConfig.TagColorPair
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -65,17 +66,83 @@ import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.toTimeAgo
 import kotlinx.collections.immutable.ImmutableList
 
-/**
- * 通用的书架条目布局组件
- * 支持 列表/网格 模式及 标准/紧凑/仅封面 样式
- */
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
-fun BookshelfItem(
+fun BookshelfGridItem(
+    cover: @Composable (Modifier) -> Unit,
+    title: String,
+    gridStyle: Int,
+    modifier: Modifier = Modifier,
+    isSelected: Boolean = false,
+    titleSmallFont: Boolean = false,
+    titleCenter: Boolean = true,
+    titleMaxLines: Int = 2,
+    coverShadow: Boolean = false,
+    accessibilityLabel: String? = null,
+    coverWidth: Int = 84,
+    onClick: () -> Unit,
+    onLongClick: (() -> Unit)?
+) {
+    Box(
+        modifier = modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(4.dp))
+            .then(if (isSelected) Modifier.background(LegadoTheme.colorScheme.secondaryContainer) else Modifier)
+            .combinedClickable(role = Role.Button, onClick = onClick, onLongClick = onLongClick)
+            .bookshelfItemSemantics(accessibilityLabel ?: title, isSelected)
+    ) {
+        Column(
+            horizontalAlignment = Alignment.CenterHorizontally,
+            modifier = Modifier
+                .align(Alignment.Center)
+                .width(coverWidth.dp)
+        ) {
+            BookshelfItemCover(coverShadow = coverShadow, cover = cover) {
+                if (gridStyle == 1) {
+                    AppText(
+                        text = title,
+                        style = (if (titleSmallFont) LegadoTheme.typography.labelSmall else LegadoTheme.typography.labelMedium).copy(
+                            color = Color.White,
+                            shadow = Shadow(color = Color.Black.copy(alpha = 0.5f), blurRadius = 4f)
+                        ),
+                        textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier
+                            .align(Alignment.BottomStart)
+                            .fillMaxWidth()
+                            .background(
+                                Brush.verticalGradient(
+                                    listOf(
+                                        Color.Transparent,
+                                        Color.Black.copy(alpha = 0.7f)
+                                    )
+                                )
+                            )
+                            .padding(all = 4.dp)
+                    )
+                }
+            }
+            if (gridStyle == 0) {
+                AppText(
+                    text = title,
+                    style = if (titleSmallFont) LegadoTheme.typography.labelSmall else LegadoTheme.typography.labelMedium,
+                    maxLines = titleMaxLines,
+                    overflow = TextOverflow.Ellipsis,
+                    textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(start = 4.dp, end = 4.dp, bottom = 4.dp)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun BookshelfListItem(
     settings: BookshelfSettings,
-    isGrid: Boolean,
-    gridStyle: Int, // 0: Standard, 1: Compact, 2: Cover Only
-    isCompact: Boolean, // For List Mode
+    isCompact: Boolean,
     cover: @Composable (Modifier) -> Unit,
     title: String,
     modifier: Modifier = Modifier,
@@ -83,237 +150,125 @@ fun BookshelfItem(
     titleEnd: @Composable (() -> Unit)? = null,
     subTitle: String? = null,
     desc: String? = null,
+    descAnnotated: AnnotatedString? = null,
     descMaxLines: Int = 1,
     extra: @Composable (RowScope.() -> Unit)? = null,
     columnContent: @Composable (ColumnScope.() -> Unit)? = null,
     bottomContent: @Composable (() -> Unit)? = null,
-    titleSmallFont: Boolean = false,
-    titleCenter: Boolean = true,
     titleMaxLines: Int = 2,
     coverShadow: Boolean = false,
     titleColor: Color? = null,
-    descAnnotated: AnnotatedString? = null,
     accessibilityLabel: String? = null,
     coverWidth: Int = 84,
     onClick: () -> Unit,
     onLongClick: (() -> Unit)?
 ) {
-    val isDark = LegadoTheme.isDark
-    val bookshelfCardColor =
-        if (isDark) settings.bookshelfCardColorDark else settings.bookshelfCardColor
-    val containerColor = if (!isGrid && bookshelfCardColor != 0) {
-        Color(bookshelfCardColor)
-    } else {
-        LegadoTheme.colorScheme.cardContainer
-    }
-    val itemAccessibilityLabel = accessibilityLabel ?: title
-    val accessibilityModifier = Modifier.semantics(mergeDescendants = true) {
-        contentDescription = itemAccessibilityLabel
-        role = Role.Button
-        if (isSelected) {
-            selected = true
-        }
-    }
-
-    if (isGrid) {
-        Box(
+    val cardColor =
+        if (LegadoTheme.isDark) settings.bookshelfCardColorDark else settings.bookshelfCardColor
+    Column {
+        NormalCard(
             modifier = modifier
                 .fillMaxWidth()
-                .clip(RoundedCornerShape(4.dp))
-                .then(
-                    if (isSelected) {
-                        Modifier.background(LegadoTheme.colorScheme.secondaryContainer)
-                    } else {
-                        Modifier
-                    }
-                )
-                .combinedClickable(
-                    role = Role.Button,
-                    onClick = onClick,
-                    onLongClick = onLongClick
-                )
-                .then(accessibilityModifier)
+                .padding(vertical = 4.dp)
+                .bookshelfItemSemantics(accessibilityLabel ?: title, isSelected),
+            cornerRadius = 8.dp,
+            containerColor = if (isSelected) LegadoTheme.colorScheme.secondaryContainer else if (cardColor != 0) Color(
+                cardColor
+            ) else LegadoTheme.colorScheme.cardContainer,
+            onClick = onClick,
+            onLongClick = onLongClick
         ) {
-            Column(
-                horizontalAlignment = Alignment.CenterHorizontally,
-                modifier = Modifier
-                    .align(Alignment.Center)
-                    .width(coverWidth.dp)
-            ) {
-
-                Box(
+            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+                Box(Modifier
+                    .align(Alignment.CenterVertically)
+                    .width(coverWidth.dp)) {
+                    BookshelfItemCover(coverShadow = coverShadow, cover = cover)
+                }
+                Column(
                     modifier = Modifier
-                        .padding(4.dp)
-                        .fillMaxWidth()
-                        .aspectRatio(5f / 7f)
-                        .then(
-                            if (coverShadow) Modifier.shadow(
-                                4.dp,
-                                RoundedCornerShape(4.dp)
-                            ) else Modifier
-                        )
-                        .clip(RoundedCornerShape(4.dp))
+                        .weight(1f)
+                        .padding(top = 4.dp, bottom = 4.dp, end = 8.dp, start = 4.dp),
+                    verticalArrangement = Arrangement.Center
                 ) {
-                    cover(Modifier.fillMaxSize())
-                    if (gridStyle == 1) {
+                    Row(verticalAlignment = Alignment.Top) {
                         AppText(
                             text = title,
-                            style = (if (titleSmallFont) LegadoTheme.typography.labelSmall else LegadoTheme.typography.labelMedium).copy(
-                                color = Color.White,
-                                shadow = Shadow(
-                                    color = Color.Black.copy(alpha = 0.5f),
-                                    blurRadius = 4f
-                                )
-                            ),
-                            textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
-                            maxLines = 2,
+                            style = if (titleColor != null) LegadoTheme.typography.titleMediumEmphasized.copy(
+                                color = titleColor
+                            ) else LegadoTheme.typography.titleMediumEmphasized,
+                            maxLines = titleMaxLines,
                             overflow = TextOverflow.Ellipsis,
-                            modifier = Modifier
-                                .align(Alignment.BottomStart)
-                                .fillMaxWidth()
-                                .background(
-                                    Brush.verticalGradient(
-                                        colors = listOf(
-                                            Color.Transparent,
-                                            Color.Black.copy(alpha = 0.7f)
-                                        )
-                                    )
-                                )
-                                .padding(all = 4.dp)
+                            modifier = Modifier.weight(1f)
                         )
+                        titleEnd?.let { Box(Modifier.padding(top = 4.dp, start = 4.dp)) { it() } }
                     }
-                }
-
-                if (gridStyle == 0) {
-                    AppText(
-                        text = title,
-                        style = if (titleSmallFont) LegadoTheme.typography.labelSmall else LegadoTheme.typography.labelMedium,
-                        maxLines = titleMaxLines,
-                        overflow = TextOverflow.Ellipsis,
-                        textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(start = 4.dp, end = 4.dp, bottom = 4.dp)
-                    )
-                }
-            }
-        }
-    } else {
-        Column {
-            NormalCard(
-                modifier = modifier
-                    .fillMaxWidth()
-                    .padding(vertical = 4.dp)
-                    .then(accessibilityModifier),
-                cornerRadius = 8.dp,
-                containerColor = if (isSelected) {
-                    LegadoTheme.colorScheme.secondaryContainer
-                } else {
-                    containerColor
-                },
-                onClick = onClick,
-                onLongClick = onLongClick
-            ) {
-                Row(
-                    modifier = Modifier
-                        .fillMaxWidth(),
-                    verticalAlignment = Alignment.CenterVertically
-                ) {
-                    Box(
-                        modifier = Modifier
-                            .align(Alignment.CenterVertically)
-                            .width(coverWidth.dp)
-                    ) {
-                        Box(
-                            modifier = Modifier
-                                .padding(4.dp)
-                                .fillMaxWidth()
-                                .aspectRatio(5f / 7f)
-                                .then(
-                                    if (coverShadow) Modifier.shadow(
-                                        4.dp,
-                                        RoundedCornerShape(4.dp)
-                                    ) else Modifier
-                                )
-                                .clip(RoundedCornerShape(4.dp))
-                        ) {
-                            cover(Modifier.fillMaxSize())
-                        }
-                    }
-                    Column(
-                        modifier = Modifier
-                            .weight(1f)
-                            .padding(top = 4.dp, bottom = 4.dp, end = 8.dp, start = 4.dp),
-                        verticalArrangement = Arrangement.Center
-                    ) {
-                    Row(
-                        verticalAlignment = Alignment.Top
-                    ) {
+                    subTitle?.let {
                         AppText(
-                            text = title,
-                            style = if (titleColor != null) {
-                                LegadoTheme.typography.titleMediumEmphasized.copy(color = titleColor)
-                            } else {
-                                LegadoTheme.typography.titleMediumEmphasized
-                            },
-                            maxLines = settings.bookshelfTitleMaxLines,
+                            text = it,
+                            style = LegadoTheme.typography.bodySmall,
+                            maxLines = 1,
                             overflow = TextOverflow.Ellipsis,
-                            modifier = Modifier.weight(1f)
                         )
-                        titleEnd?.let {
-                            Box(modifier = Modifier.padding(top = 4.dp, start = 4.dp)) {
-                                it.invoke()
-                            }
-                        }
                     }
-                        subTitle?.let {
+                    if (!isCompact) {
+                        descAnnotated?.let {
                             AppText(
                                 text = it,
-                                style = LegadoTheme.typography.bodySmall,
-                                maxLines = 1,
+                                style = LegadoTheme.typography.labelSmallEmphasized,
+                                maxLines = descMaxLines,
                                 overflow = TextOverflow.Ellipsis,
                             )
-                        }
-                        if (!isCompact) {
-                            if (descAnnotated != null) {
-                                AppText(
-                                    text = descAnnotated,
-                                    style = LegadoTheme.typography.labelSmallEmphasized,
-                                    maxLines = descMaxLines,
-                                    overflow = TextOverflow.Ellipsis
-                                )
-                            } else {
-                                desc?.let {
-                                    AppText(
-                                        text = it,
-                                        style = LegadoTheme.typography.labelSmallEmphasized,
-                                        maxLines = descMaxLines,
-                                        overflow = TextOverflow.Ellipsis
-                                    )
-                                }
-                            }
-                        }
-                        extra?.let {
-                            Row(
-                                verticalAlignment = Alignment.CenterVertically,
-                                content = it
+                        } ?: desc?.let {
+                            AppText(
+                                text = it,
+                                style = LegadoTheme.typography.labelSmallEmphasized,
+                                maxLines = descMaxLines,
+                                overflow = TextOverflow.Ellipsis,
                             )
                         }
-                        columnContent?.invoke(this)
                     }
+                    extra?.let { Row(verticalAlignment = Alignment.CenterVertically, content = it) }
+                    columnContent?.invoke(this)
                 }
-                bottomContent?.invoke()
             }
-            if (settings.bookshelfShowDivider)
-                HorizontalDivider(
-                    modifier = Modifier.padding(horizontal = 16.dp),
-                    thickness = 0.5.dp,
-                    color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
-                )
+            bottomContent?.invoke()
+        }
+        if (settings.bookshelfShowDivider) {
+            HorizontalDivider(
+                Modifier.padding(horizontal = 16.dp),
+                thickness = 0.5.dp,
+                color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
+            )
         }
     }
 }
 
+@Composable
+private fun BookshelfItemCover(
+    coverShadow: Boolean,
+    cover: @Composable (Modifier) -> Unit,
+    overlay: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit = {},
+) {
+    Box(
+        modifier = Modifier
+            .padding(4.dp)
+            .fillMaxWidth()
+            .aspectRatio(5f / 7f)
+            .then(if (coverShadow) Modifier.shadow(4.dp, RoundedCornerShape(4.dp)) else Modifier)
+            .clip(RoundedCornerShape(4.dp))
+    ) {
+        cover(Modifier.fillMaxSize())
+        overlay()
+    }
+}
+
+private fun Modifier.bookshelfItemSemantics(label: String, isSelected: Boolean): Modifier =
+    semantics(mergeDescendants = true) {
+        contentDescription = label
+        role = Role.Button
+        if (isSelected) selected = true
+    }
+
 @Composable
 fun BookGroupCover(
     settings: BookshelfSettings,
@@ -446,11 +401,8 @@ fun BookGroupItemGrid(
     onClick: () -> Unit,
     onLongClick: (() -> Unit)?
 ) {
-    BookshelfItem(
-        settings = settings,
-        isGrid = true,
+    BookshelfGridItem(
         gridStyle = gridStyle,
-        isCompact = false,
         cover = {
             BookGroupCover(
                 settings = settings,
@@ -482,8 +434,6 @@ fun BookGroupItemList(
     modifier: Modifier = Modifier,
     countText: String? = null,
     isCompact: Boolean = false,
-    titleSmallFont: Boolean = false,
-    titleCenter: Boolean = true,
     titleMaxLines: Int = 2,
     coverShadow: Boolean = false,
     onLongClick: (() -> Unit)? = null,
@@ -513,10 +463,8 @@ fun BookGroupItemList(
     } else {
         null
     }
-    BookshelfItem(
+    BookshelfListItem(
         settings = settings,
-        isGrid = false,
-        gridStyle = 0,
         isCompact = settings.bookshelfGroupListStyle == 1 || isCompact,
         cover = {
             BookGroupCover(
@@ -534,8 +482,6 @@ fun BookGroupItemList(
             countText,
             firstBookName?.let { "${stringResource(R.string.recently_read)}$it" },
         ),
-        titleSmallFont = titleSmallFont,
-        titleCenter = titleCenter,
         titleMaxLines = titleMaxLines,
         coverShadow = coverShadow,
         coverWidth = settings.bookshelfListCoverWidth,
@@ -684,6 +630,17 @@ fun BookItem(
     onLongClick: (() -> Unit)?
 ) {
     val book = bookUi.book
+    val intro = remember(book.intro, settings.bookshelfIntroMaxLines) {
+        val formatted = if (settings.bookshelfIntroMaxLines == 0) {
+            HtmlFormatter.formatIntroText(book.intro)
+        } else {
+            HtmlFormatter.formatSummaryText(book.intro)
+        }
+        formatted.takeIf { it.isNotBlank() }
+    }
+    val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
+    val showIntro = showListDetails && settings.bookshelfShowIntro && intro != null
+    val showIntroBelowContent = showIntro && settings.bookshelfListIntroBelowContent
     val unreadCount = book.getUnreadChapterNum()
     val unreadText = if (settings.showUnread && unreadCount > 0) unreadCount.toString() else null
     val showUpdateBadge = settings.showUnread && settings.showUnreadNew && book.isNew
@@ -708,14 +665,64 @@ fun BookItem(
         null
     }
 
-    BookshelfItem(
+    val cover: @Composable (Modifier) -> Unit = { coverModifier ->
+        BookshelfCover(
+            name = book.name,
+            author = book.author,
+            path = book.getDisplayCover(),
+            isUpdating = isUpdating,
+            modifier = coverModifier,
+            coverModifier = Modifier
+                .fillMaxWidth()
+                .aspectRatio(5f / 7f),
+            sourceOrigin = book.origin,
+            badgeText = if (layoutMode != 0) unreadText else null,
+            showBadgeDot = showUpdateBadge,
+            leftBottomText = matchedSourceLabel ?: bookTypeLabel,
+            showLoadingPlaceholder = true,
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = animatedVisibilityScope,
+            sharedCoverKey = sharedCoverKey,
+        )
+    }
+    val accessibilityLabel = bookAccessibilityLabel(
+        name = book.name,
+        author = book.author,
+        unreadText?.let { "$it ${stringResource(R.string.is_unread)}" },
+        if (isUpdating) stringResource(R.string.loading) else null,
+        book.durChapterTitle,
+        book.latestChapterTitle,
+        matchedSourceLabel,
+        bookTypeLabel,
+    )
+
+    if (layoutMode != 0) {
+        BookshelfGridItem(
+            cover = cover,
+            title = book.name,
+            gridStyle = gridStyle,
+            modifier = modifier,
+            isSelected = isSelected,
+            titleSmallFont = titleSmallFont,
+            titleCenter = titleCenter,
+            titleMaxLines = titleMaxLines,
+            coverShadow = coverShadow,
+            accessibilityLabel = accessibilityLabel,
+            coverWidth = settings.bookshelfGridCoverWidth,
+            onClick = onClick,
+            onLongClick = onLongClick,
+        )
+        return
+    }
+
+    BookshelfListItem(
         settings = settings,
-        isGrid = layoutMode != 0,
-        gridStyle = gridStyle,
         isCompact = isCompact,
-        isSelected = isSelected,
+        cover = cover,
+        title = book.name,
         modifier = modifier,
-        titleEnd = if (layoutMode == 0 && unreadText != null) {
+        isSelected = isSelected,
+        titleEnd = if (unreadText != null) {
             {
                 TextCard(
                     text = unreadText,
@@ -727,50 +734,15 @@ fun BookItem(
                 )
             }
         } else null,
-        cover = { modifier ->
-            BookshelfCover(
-                name = book.name,
-                author = book.author,
-                path = book.getDisplayCover(),
-                isUpdating = isUpdating,
-                modifier = modifier,
-                coverModifier = Modifier
-                    .fillMaxWidth()
-                    .aspectRatio(5f / 7f),
-                sourceOrigin = book.origin,
-                badgeText = if (layoutMode != 0) unreadText else null,
-                showBadgeDot = showUpdateBadge,
-                leftBottomText = matchedSourceLabel ?: bookTypeLabel,
-                showLoadingPlaceholder = true,
-                sharedTransitionScope = sharedTransitionScope,
-                animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = sharedCoverKey,
-            )
-        },
-        title = book.name,
-        subTitle = if (layoutMode == 0 && isCompact) {
+        subTitle = if (isCompact) {
             stringResource(R.string.author_read, book.author, unreadCount)
         } else {
             book.author
         },
         desc = book.durChapterTitle ?: "",
-        columnContent = if (layoutMode == 0 && !isCompact && settings.showBookIntro) {
+        columnContent = if (showListDetails) {
             {
                 val kindList = bookUi.displayTags
-                //不限行数时保留分段, 限行数时压成摘要, 否则第一段之后会被直接截掉
-                val introMaxLines = if (settings.bookshelfIntroMaxLines == 0) {
-                    Int.MAX_VALUE
-                } else {
-                    settings.bookshelfIntroMaxLines
-                }
-                val intro = remember(book.intro, introMaxLines) {
-                    val text = if (introMaxLines == Int.MAX_VALUE) {
-                        HtmlFormatter.formatIntroText(book.intro)
-                    } else {
-                        HtmlFormatter.formatSummaryText(book.intro)
-                    }
-                    text.takeIf { it.isNotBlank() }
-                }
                 if (settings.bookshelfShowTag && kindList.isNotEmpty()) {
                     Row(
                         modifier = Modifier
@@ -788,7 +760,9 @@ fun BookItem(
                             }
                             TextCard(
                                 text = label,
-                                backgroundColor = if (colorPair != null && colorPair.bgColor != 0) Color(colorPair.bgColor) else LegadoTheme.colorScheme.surfaceContainerHigh,
+                                backgroundColor = if (colorPair != null && colorPair.bgColor != 0) Color(
+                                    colorPair.bgColor
+                                ) else LegadoTheme.colorScheme.cardPrimaryContainer,
                                 contentColor = if (colorPair != null && colorPair.textColor != 0) Color(colorPair.textColor) else LegadoTheme.colorScheme.primary.copy(alpha = 0.8f),
                                 cornerRadius = 4.dp,
                                 horizontalPadding = 4.dp,
@@ -798,22 +772,30 @@ fun BookItem(
                         }
                     }
                 }
-                if (settings.bookshelfShowIntro && intro != null) {
-                    AppText(
-                        text = intro,
-                        style = LegadoTheme.typography.bodySmall,
-                        color = LegadoTheme.colorScheme.onSurfaceVariant,
-                        maxLines = introMaxLines,
-                        overflow = TextOverflow.Ellipsis,
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(vertical = 4.dp)
+                if (showIntro && !showIntroBelowContent) {
+                    BookItemIntro(
+                        intro = intro,
+                        maxLines = settings.bookshelfIntroMaxLines,
+                    )
+                }
+            }
+        } else null,
+        bottomContent = if (showIntroBelowContent) {
+            {
+                GlassCard(
+                    modifier = Modifier.padding(all = 4.dp),
+                    cornerRadius = 4.dp,
+                    containerColor = LegadoTheme.colorScheme.cardContainer
+                ) {
+                    BookItemIntro(
+                        intro = intro,
+                        maxLines = settings.bookshelfIntroMaxLines,
+                        modifier = Modifier.padding(horizontal = 8.dp),
                     )
                 }
             }
         } else null,
-        bottomContent = null,
-        extra = if (layoutMode == 0 && !isCompact && settings.showBookIntro && settings.bookshelfShowLatestChapter) {
+        extra = if (showListDetails && settings.bookshelfShowLatestChapter) {
             {
                 if (settings.showLastUpdateTime && !book.isLocal) {
                     AppText(
@@ -833,23 +815,30 @@ fun BookItem(
                 )
             }
         } else null,
-        titleSmallFont = titleSmallFont,
-        titleCenter = titleCenter,
         titleMaxLines = titleMaxLines,
         coverShadow = coverShadow,
-        accessibilityLabel = bookAccessibilityLabel(
-            name = book.name,
-            author = book.author,
-            unreadText?.let { "$it ${stringResource(R.string.is_unread)}" },
-            if (isUpdating) stringResource(R.string.loading) else null,
-            book.durChapterTitle,
-            book.latestChapterTitle,
-            matchedSourceLabel,
-            bookTypeLabel,
-        ),
-        coverWidth = if (layoutMode == 0) settings.bookshelfListCoverWidth else settings.bookshelfGridCoverWidth,
+        accessibilityLabel = accessibilityLabel,
+        coverWidth = settings.bookshelfListCoverWidth,
         onClick = onClick,
-        onLongClick = onLongClick
+        onLongClick = onLongClick,
+    )
+}
+
+@Composable
+private fun BookItemIntro(
+    intro: String,
+    maxLines: Int,
+    modifier: Modifier = Modifier,
+) {
+    AppText(
+        text = intro,
+        style = LegadoTheme.typography.bodySmall,
+        color = LegadoTheme.colorScheme.onSurfaceVariant,
+        maxLines = if (maxLines == 0) Int.MAX_VALUE else maxLines,
+        overflow = TextOverflow.Ellipsis,
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
     )
 }
 

+ 10 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt

@@ -448,6 +448,16 @@ fun BookshelfConfigSheet(
                                     onUpdate { it.copy(bookshelfShowIntro = value) }
                                 }
                             )
+                            AnimatedVisibility(visible = settings.bookshelfShowIntro && layoutMode == 0) {
+                                CompactSwitchSettingItem(
+                                    title = stringResource(R.string.show_synopsis_below_content),
+                                    checked = settings.bookshelfListIntroBelowContent,
+                                    color = LegadoTheme.colorScheme.surface,
+                                    onCheckedChange = { value ->
+                                        onUpdate { it.copy(bookshelfListIntroBelowContent = value) }
+                                    }
+                                )
+                            }
                             AnimatedVisibility(
                                 visible = settings.bookshelfShowIntro
                             ) {

+ 0 - 2
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -863,8 +863,6 @@ fun BookshelfScreen(
                                         ?: emptyList(),
                                     countText = countText,
                                     isCompact = uiState.settings.bookshelfLayoutCompact,
-                                    titleSmallFont = uiState.settings.bookshelfTitleSmallFont,
-                                    titleCenter = uiState.settings.bookshelfTitleCenter,
                                     titleMaxLines = uiState.settings.bookshelfTitleMaxLines,
                                     coverShadow = uiState.settings.bookshelfCoverShadow,
                                     onClick = {

+ 1 - 2
app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.theme
 
 import androidx.compose.animation.animateColor
-import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.animation.core.tween
@@ -72,7 +71,7 @@ fun ColorScheme.toLegadoColorScheme(
         tertiaryFixedDim = tertiaryFixedDim,
         onTertiaryFixed = onTertiaryFixed,
         onTertiaryFixedVariant = onTertiaryFixedVariant,
-        cardContainer = primaryContainer.copy(alpha = 0.5f),
+        cardContainer = surfaceContainerLow,
         onCardContainer = primary,
         onSheetContent = surface,
         cardPrimaryContainer = primaryContainer,

+ 7 - 2
app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.widget.components
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -105,7 +106,9 @@ fun AppScaffold(
                                 )
                                 .then(
                                     if (contentDrawsBehindBars) Modifier
-                                    else Modifier.padding(scaffoldPadding)
+                                    else Modifier
+                                        .padding(scaffoldPadding)
+                                        .consumeWindowInsets(scaffoldPadding)
                                 )
                         ) {
                             content(
@@ -147,7 +150,9 @@ fun AppScaffold(
                                 )
                                 .then(
                                     if (contentDrawsBehindBars) Modifier
-                                    else Modifier.padding(scaffoldPadding)
+                                    else Modifier
+                                        .padding(scaffoldPadding)
+                                        .consumeWindowInsets(scaffoldPadding)
                                 )
                         ) {
                             content(

+ 13 - 2
app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt

@@ -3,8 +3,14 @@ package io.legado.app.ui.widget.components
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.tappableElement
+import androidx.compose.foundation.layout.union
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.ExpandMore
 import androidx.compose.material.icons.filled.Refresh
@@ -72,10 +78,15 @@ fun SelectionBottomBar(
     val selectAllDescription = stringResource(R.string.select_all)
     val invertSelectionDescription = stringResource(R.string.invert_selection)
     val moreActionsDescription = stringResource(R.string.more_menu)
+    val safeModifier = modifier.windowInsetsPadding(
+        WindowInsets.navigationBars
+            .union(WindowInsets.tappableElement)
+            .union(WindowInsets.ime)
+    )
 
     if (isMiuix) {
         MiuixFloatingToolbar(
-            modifier = modifier
+            modifier = safeModifier
         ) {
             Row(
                 verticalAlignment = Alignment.CenterVertically,
@@ -135,7 +146,7 @@ fun SelectionBottomBar(
         }
     } else {
         HorizontalFloatingToolbar(
-            modifier = modifier,
+            modifier = safeModifier,
             expanded = true,
             leadingContent = {
                 IconButton(onClick = onSelectAll) {

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

@@ -2328,6 +2328,7 @@
     <string name="night_card_bg_color">夜间卡片背景颜色</string>
     <string name="compact_details">精简详情</string>
     <string name="show_more_info">显示更多信息</string>
+    <string name="show_synopsis_below_content">简介显示在封面和信息下方</string>
     <string name="show_latest_chapter">显示最新章节</string>
     <string name="show_synopsis">显示简介</string>
     <string name="synopsis_lines">简介行数</string>

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

@@ -2106,6 +2106,7 @@
     <string name="show_lines_synopsis">顯示簡介 %1$d 行</string>
     <string name="show_menu_icon">顯示選單項目圖標</string>
     <string name="show_more_info">顯示更多資訊</string>
+    <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_original">顯示原文</string>
     <string name="show_password">顯示密碼</string>
     <string name="show_select_menu_icon">展示圖標</string>

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

@@ -2244,6 +2244,7 @@
     <string name="night_card_bg_color">夜間卡片背景色</string>
     <string name="compact_details">精簡詳情</string>
     <string name="show_more_info">顯示更多資訊</string>
+    <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_latest_chapter">顯示最新章節</string>
     <string name="show_synopsis">顯示簡介</string>
     <string name="synopsis_lines">簡介行數</string>

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

@@ -2352,6 +2352,7 @@
     <string name="night_card_bg_color">Night Card Background Color</string>
     <string name="compact_details">Compact Details</string>
     <string name="show_more_info">Show More Info</string>
+    <string name="show_synopsis_below_content">Show Synopsis Below Cover and Details</string>
     <string name="show_latest_chapter">Show Latest Chapter</string>
     <string name="show_synopsis">Show Synopsis</string>
     <string name="synopsis_lines">Synopsis Lines</string>

+ 25 - 0
app/src/test/java/io/legado/app/BookSearchScopeTest.kt

@@ -0,0 +1,25 @@
+package io.legado.app
+
+import io.legado.app.domain.model.BookSearchScope
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BookSearchScopeTest {
+
+    @Test
+    fun encodedSingleSourceScopeKeepsOnlyTheSelectedSource() {
+        val sourceName = "Example: source, one"
+        val sourceUrl = "https://example.com/search"
+        val raw = BookSearchScope.encodeSource(sourceName, sourceUrl)
+
+        val scope = BookSearchScope(raw)
+
+        assertTrue(scope.isSource)
+        assertFalse(scope.isAll)
+        assertEquals(listOf(sourceName), scope.sourceNames)
+        assertEquals(listOf(sourceUrl), scope.sourceUrls)
+        assertTrue(scope.groupNames.isEmpty())
+    }
+}

+ 5 - 2
app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt

@@ -8,8 +8,8 @@ import org.junit.Test
 class BookshelfSettingsMappingTest {
 
     @Test
-    fun `Bookshelf 46 键写读映射逐字段对应`() {
-        assertEquals(46, BookshelfSettings().toPrefMap().size)
+    fun `Bookshelf 47 键写读映射逐字段对应`() {
+        assertEquals(47, BookshelfSettings().toPrefMap().size)
         bookshelfMappingSamples().forEach { expected ->
             assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
             assertEquals(
@@ -66,6 +66,7 @@ private fun bookshelfMappingSamples(): List<BookshelfSettings> {
         bookshelfFolderLayoutListLandscape = 117,
         bookshelfGridLayout = 118,
         bookshelfLayoutCompact = false,
+        bookshelfListIntroBelowContent = false,
         bookshelfShowDivider = false,
         bookshelfTitleSmallFont = false,
         bookshelfTitleCenter = false,
@@ -97,6 +98,7 @@ private fun bookshelfMappingSamples(): List<BookshelfSettings> {
         base.copy(showBookshelfFastScroller = true),
         base.copy(shouldShowExpandButton = true),
         base.copy(bookshelfLayoutCompact = true),
+        base.copy(bookshelfListIntroBelowContent = true),
         base.copy(bookshelfShowDivider = true),
         base.copy(bookshelfTitleSmallFont = true),
         base.copy(bookshelfTitleCenter = true),
@@ -139,6 +141,7 @@ private fun BookshelfSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
     PreferKey.bookshelfTitleCenter to bookshelfTitleCenter,

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

@@ -45,6 +45,16 @@ class ReadBookDomainSplitBoundaryTest {
         )
     }
 
+    @Test
+    fun `菜单书签保存章节内字符位置而不是页码`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookmarkDelegate.kt").readText()
+        assertTrue(
+            "书签 chapterPos 必须保存章节内字符位置,否则跳转时页码会被误当成字符偏移",
+            "chapterPos = ReadBook.durChapterPos" in source &&
+                "chapterPos = ReadBook.durPageIndex" !in source,
+        )
+    }
+
     @Test
     fun `ReadBookViewModel 不再持有各域的实现`() {
         val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()

+ 33 - 0
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

@@ -30,6 +30,39 @@ class ReadViewBoundaryTest {
         )
     }
 
+    @Test
+    fun `ReadView 正文保持为可刷新的读屏文本节点`() {
+        val source = stripComments(readViewSource())
+        val contracts = mapOf(
+            "读屏焦点" to Regex(
+                """ViewCompat\.setScreenReaderFocusable\(\s*this\s*,\s*true\s*\)"""
+            ),
+            "绘制子树排除" to Regex(
+                """importantForAccessibility\s*=\s*View\.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS"""
+            ),
+            "正文文本" to Regex("""info\.text\s*=\s*accessibilityPageText"""),
+            "当前页文本同步" to Regex(
+                """val\s+text\s*=\s*pageFactory\.curPage\.text\s+updateAccessibilityPageText\(\s*text\s*\)"""
+            ),
+            "触摸探索归属" to Regex(
+                """override\s+fun\s+onInterceptHoverEvent\s*\([^)]*\)\s*:\s*Boolean\s*=\s*true"""
+            ),
+            "翻页内容通知" to Regex(
+                """sendAccessibilityEvent\(\s*AccessibilityEvent\.TYPE_WINDOW_CONTENT_CHANGED\s*\)"""
+            ),
+            "文本变更类型" to Regex(
+                """event\.contentChangeTypes\s*=\s*AccessibilityEvent\.CONTENT_CHANGE_TYPE_TEXT"""
+            ),
+        )
+        val missing = contracts.filterValues { !it.containsMatchIn(source) }.keys
+        assertTrue(
+            "ReadView 的页级读屏契约缺失:${missing.joinToString()}。\n" +
+                "Canvas 正文没有独立文本节点;必须由 ReadView 保持可聚焦、提供当前页文本," +
+                "并在翻页时通知无障碍服务刷新。",
+            missing.isEmpty(),
+        )
+    }
+
     @Test
     fun `TextPageFactory 不引用业务单例`() {
         val violations = businessSingletonViolations(