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

feat: 漫画小说听书相关神秘优化
值得一提听书目录改成了封面左侧,去掉按钮了SIUUUUUUUUUUUUUUUUUUU锟斤拷锟斤拷

HapeLee 1 месяц назад
Родитель
Сommit
fb01a76ebb
22 измененных файлов с 328 добавлено и 225 удалено
  1. 21 0
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt
  2. 1 1
      app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt
  3. 1 1
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  4. 1 1
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  5. 13 76
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt
  6. 15 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt
  7. 4 3
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt
  8. 18 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt
  9. 49 17
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  10. 84 12
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt
  11. 42 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt
  12. 18 0
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  13. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  14. 15 6
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt
  15. 22 17
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt
  16. 1 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  17. 16 88
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.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. 2 1
      app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapterTest.kt

+ 21 - 0
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt

@@ -13,6 +13,7 @@ import io.legado.app.data.AppDatabase
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book.ReadConfig
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.exception.NoStackTraceException
@@ -46,6 +47,12 @@ sealed interface MangaReaderActionPaymentResult {
     data object Refreshed : MangaReaderActionPaymentResult
 }
 
+data class MangaSourceCustomButtonPayload(
+    val source: BookSource,
+    val book: Book,
+    val chapter: BookChapter?,
+)
+
 /** One-shot reader operations. Every operation receives explicit session identity. */
 class MangaReaderActionRepository(
     private val application: Application,
@@ -59,6 +66,20 @@ class MangaReaderActionRepository(
     suspend fun refreshSource(sourceOrigin: String?) =
         sourceOrigin?.let { database.bookSourceDao.getBookSource(it) }
 
+    suspend fun getSourceCustomButtonPayload(
+        bookUrl: String,
+        chapterIndex: Int,
+    ): MangaSourceCustomButtonPayload? {
+        val book = database.bookDao.getBook(bookUrl) ?: return null
+        val source = database.bookSourceDao.getBookSource(book.origin)
+            ?.takeIf { it.customButton } ?: return null
+        return MangaSourceCustomButtonPayload(
+            source = source,
+            book = book,
+            chapter = database.bookChapterDao.getChapter(bookUrl, chapterIndex),
+        )
+    }
+
     suspend fun disableSource(sourceOrigin: String?) {
         val source = sourceOrigin?.let { database.bookSourceDao.getBookSource(it) } ?: return
         source.enabled = false

+ 1 - 1
app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt

@@ -92,4 +92,4 @@ private fun String.lineParagraphs(): List<ReaderReadAloudParagraph> = buildList
     if (start < length) add(ReaderReadAloudParagraph(substring(start), start, true))
 }
 
-private fun String.sanitizeForSpeech(): String = replace(Regex("[袮祢꧁]"), " ")
+private fun String.sanitizeForSpeech(): String = replace(Regex("[袮祢꧁\uFFFC]"), " ")

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

@@ -415,7 +415,7 @@ abstract class BaseReadAloudService : BaseService(),
             val startsAtChapterBeginning = preparedReadAloudNumber == 0
             val preparedParagraphs = preparedChapter.paragraphs(preparedReadAloudByPage)
             var preparedContentList = preparedParagraphs
-                .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
+                .map { it.text.replace(Regex("[袮祢꧁\uFFFC]"), " ") }
             var preparedContentChapterPositions: List<Int?> =
                 preparedParagraphs.map { it.chapterPosition }
             val preparedSpeechPlan = buildSpeechPlan(

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

@@ -423,7 +423,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         } else {
             listOf(displayTitle.trim()).filter { it.isNotEmpty() } +
                     readAloudChapter.paragraphs(readAloudSettings.readAloudByPage)
-                .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
+                        .map { it.text.replace(Regex("[袮祢꧁\uFFFC]"), " ") }
         }
         return PreDownloadChapter(displayTitle, queue, contentList)
     }

+ 13 - 76
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.book.audio
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
@@ -24,12 +23,10 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.automirrored.filled.Login
 import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
 import androidx.compose.material.icons.automirrored.filled.VolumeUp
@@ -42,7 +39,6 @@ import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.Headphones
 import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.Lock
 import androidx.compose.material.icons.filled.RadioButtonUnchecked
 import androidx.compose.material.icons.filled.Repeat
@@ -60,7 +56,6 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
@@ -100,7 +95,6 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
-import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
 import io.legado.app.ui.widget.components.player.PlayerBackground
@@ -109,7 +103,6 @@ import io.legado.app.ui.widget.components.player.PlayerTocPage
 import io.legado.app.ui.widget.components.player.playerBgModeLabel
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
 import top.yukonga.miuix.kmp.blur.layerBackdrop
 import java.util.Locale
 import kotlin.math.abs
@@ -123,15 +116,9 @@ fun AudioPlayScreenContent(
     onBack: () -> Unit,
 ) {
     val horizontalPagerState = rememberPagerState(
-        initialPage = 0,
-        pageCount = { if (state.lyricLines.isEmpty()) 1 else 2 },
+        initialPage = 1,
+        pageCount = { if (state.lyricLines.isEmpty()) 2 else 3 },
     )
-    val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
-    val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
-        state = verticalPagerState,
-        orientation = Orientation.Vertical,
-    )
-    val coroutineScope = rememberCoroutineScope()
     var menuExpanded by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
@@ -383,46 +370,6 @@ fun AudioPlayScreenContent(
                         .padding(vertical = 16.dp, horizontal = 24.dp),
                     horizontalArrangement = Arrangement.SpaceEvenly,
                 ) {
-                    SmallAnimatedButton(
-                        containerColor = Color.Transparent,
-                        checked = false,
-                        icon = Icons.AutoMirrored.Filled.FormatListBulleted,
-                        iconChecked = Icons.Default.KeyboardArrowUp,
-                        text = stringResource(
-                            if (verticalPagerState.currentPage == 0) {
-                                R.string.chapter_list
-                            } else {
-                                R.string.back
-                            }
-                        ),
-                        contentDescription = stringResource(
-                            if (verticalPagerState.currentPage == 0) {
-                                R.string.chapter_list
-                            } else {
-                                R.string.back
-                            }
-                        ),
-                        onCheckedChange = {
-                            coroutineScope.launch {
-                                if (horizontalPagerState.currentPage != 0) {
-                                    horizontalPagerState.animateScrollToPage(
-                                        page = 0,
-                                        animationSpec = tween(
-                                            durationMillis = 520,
-                                            easing = FastOutSlowInEasing,
-                                        ),
-                                    )
-                                }
-                                verticalPagerState.animateScrollToPage(
-                                    page = if (verticalPagerState.currentPage == 0) 1 else 0,
-                                    animationSpec = tween(
-                                        durationMillis = 520,
-                                        easing = FastOutSlowInEasing,
-                                    ),
-                                )
-                            }
-                        },
-                    )
                     SmallAnimatedButton(
                         containerColor = Color.Transparent,
                         checked = false,
@@ -485,27 +432,17 @@ fun AudioPlayScreenContent(
                 modifier = Modifier.fillMaxSize(),
                 verticalAlignment = Alignment.CenterVertically,
             ) { page ->
-                if (page == 0) {
-                    VerticalPager(
-                        state = verticalPagerState,
-                        modifier = Modifier.fillMaxSize(),
-                        horizontalAlignment = Alignment.CenterHorizontally,
-                        pageNestedScrollConnection = verticalPagerNestedScrollConnection,
-                    ) { verticalPage ->
-                        if (verticalPage == 0) {
-                            AudioCoverPage(state, pageContentPadding)
-                        } else {
-                            PlayerTocPage(
-                                chapters = state.chapters,
-                                currentIndex = state.chapterIndex,
-                                isPaused = !state.isPlaying,
-                                onSelect = { onIntent(AudioPlayIntent.SelectChapter(it)) },
-                                contentPadding = pageContentPadding,
-                            )
-                        }
-                    }
-                } else {
-                    AudioLyricPage(
+                when (page) {
+                    0 -> PlayerTocPage(
+                        chapters = state.chapters,
+                        currentIndex = state.chapterIndex,
+                        isPaused = !state.isPlaying,
+                        onSelect = { onIntent(AudioPlayIntent.SelectChapter(it)) },
+                        contentPadding = pageContentPadding,
+                    )
+
+                    1 -> AudioCoverPage(state, pageContentPadding)
+                    else -> AudioLyricPage(
                         state = state,
                         contentPadding = pageContentPadding,
                         onIntent = onIntent,

+ 15 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
+import io.legado.app.data.entities.BookSource
 import io.legado.app.ui.book.manga.config.MangaScrollMode
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
@@ -23,9 +24,11 @@ data class MangaReaderUiState(
     val sourceName: String = "",
     val sourceUrl: String? = null,
     val sourceType: Int? = null,
+    val sourceCustomButtonAvailable: Boolean = false,
     val changeSourceBook: MangaBookSnapshot? = null,
     val pages: ImmutableList<MangaReaderItemUi> = persistentListOf(),
     val currentItemIndex: Int = 0,
+    val footerItemIndex: Int? = null,
     val currentPage: Int = 0,
     val pageCount: Int = 0,
     val chapterIndex: Int = 0,
@@ -206,6 +209,7 @@ sealed interface MangaReaderIntent {
     data object PayCurrentChapter : MangaReaderIntent
     data object OpenSourceLogin : MangaReaderIntent
     data object OpenSourceEdit : MangaReaderIntent
+    data class SourceCustomButton(val longClick: Boolean) : MangaReaderIntent
     data object BackPressed : MangaReaderIntent
     data object ToggleMenu : MangaReaderIntent
     data object HideMenu : MangaReaderIntent
@@ -246,6 +250,11 @@ sealed interface MangaReaderIntent {
         val currentChapterVisible: Boolean,
         val navigationId: Long,
     ) : MangaReaderIntent
+
+    data class FooterItemChanged(
+        val itemIndex: Int,
+        val navigationId: Long,
+    ) : MangaReaderIntent
     data class PagerScrollChanged(val inProgress: Boolean) : MangaReaderIntent
     data class LongPressPage(
         val pageKey: String,
@@ -340,6 +349,12 @@ sealed interface MangaReaderEffect {
     data class OpenChapterUrl(val externalBrowser: Boolean) : MangaReaderEffect
     data class OpenSourceLogin(val sourceUrl: String) : MangaReaderEffect
     data class OpenSourceEdit(val sourceUrl: String) : MangaReaderEffect
+    data class RunSourceCustomButton(
+        val event: String,
+        val source: BookSource,
+        val book: Book,
+        val chapter: BookChapter?,
+    ) : MangaReaderEffect
     data class OpenPaymentUrl(
         val url: String,
         val sourceOrigin: String?,

+ 4 - 3
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -38,7 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
-import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.filled.AutoMode
 import androidx.compose.material.icons.filled.CloudDownload
 import androidx.compose.material.icons.filled.MoreVert
@@ -119,7 +119,8 @@ internal fun BoxScope.MangaFooter(state: MangaReaderUiState) {
         2 -> Alignment.BottomEnd
         else -> Alignment.BottomStart
     }
-    val page = state.pages.getOrNull(state.currentItemIndex) as? MangaReaderItemUi.Page ?: return
+    val page = state.pages.getOrNull(state.footerItemIndex ?: state.currentItemIndex)
+            as? MangaReaderItemUi.Page ?: return
     val progress = if (page.chapterCount <= 0 || page.pageCount <= 0) 0.0 else {
         (page.chapterIndex.toDouble() + (page.pageIndex + 1.0) / page.pageCount) / page.chapterCount
     }
@@ -596,7 +597,7 @@ private fun MangaMenuBottomBar(
                             buildList {
                                 add(
                                 ReaderMenuAction(
-                                    Icons.AutoMirrored.Filled.MenuBook,
+                                    Icons.AutoMirrored.Filled.List,
                                     stringResource(R.string.chapter_list)
                                 ) {
                                     onIntent(MangaReaderIntent.OpenCatalog)

+ 18 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.manga
 import android.view.KeyEvent
 import android.view.WindowManager
 import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -13,9 +14,12 @@ import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
+import io.legado.app.constant.BookType
+import io.legado.app.model.SourceCallBack
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
+import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.openUrl
@@ -47,6 +51,11 @@ fun MangaReaderRouteScreen(
     val lifecycleOwner = LocalLifecycleOwner.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val networkChangedListener = remember(activity) { NetworkChangedListener(activity) }
+    val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
+        result?.let { (chapterIndex, chapterPos, _) ->
+            viewModel.onIntent(MangaReaderIntent.OpenChapter(chapterIndex, chapterPos))
+        }
+    }
 
     LaunchedEffect(viewModel, bookUrl, inBookshelf, chapterChanged, openRequestId) {
         viewModel.onIntent(
@@ -81,6 +90,14 @@ fun MangaReaderRouteScreen(
                 }
                 is MangaReaderEffect.OpenSourceLogin -> onOpenSourceLogin(effect.sourceUrl)
                 is MangaReaderEffect.OpenSourceEdit -> onOpenSourceEdit(effect.sourceUrl)
+                is MangaReaderEffect.RunSourceCustomButton -> SourceCallBack.callBackBtn(
+                    activity,
+                    effect.event,
+                    effect.source,
+                    effect.book,
+                    effect.chapter,
+                    BookType.image,
+                )
                 is MangaReaderEffect.OpenPaymentUrl -> onOpenWebView(
                     activity.getString(io.legado.app.R.string.chapter_pay),
                     effect.url,
@@ -203,6 +220,7 @@ fun MangaReaderRouteScreen(
                 viewModel.onIntent(MangaReaderIntent.DismissSheet)
                 onOpenBookInfo(state.bookName, state.bookAuthor, state.bookUrl)
             },
+            onOpenFullToc = { tocLauncher.launch(state.bookUrl) },
         )
     }
 }

+ 49 - 17
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -529,6 +529,28 @@ private fun WebtoonMangaList(
             }
     }
 
+    LaunchedEffect(listState) {
+        snapshotFlow {
+            listState.isScrollInProgress to
+                    listState.layoutInfo.visibleItemsInfo.map { it.index }
+        }.distinctUntilChanged().collect { (scrolling, visibleIndices) ->
+            if (!scrolling) return@collect
+            val currentState = latestReaderState
+            mangaWebtoonFocusedPageIndex(
+                items = currentState.pages,
+                visibleItemIndices = visibleIndices,
+                currentChapterIndex = currentState.chapterIndex,
+            )?.let { itemIndex ->
+                onIntent(
+                    MangaReaderIntent.FooterItemChanged(
+                        itemIndex = itemIndex,
+                        navigationId = currentState.navigationId,
+                    )
+                )
+            }
+        }
+    }
+
     LaunchedEffect(state.scrollRequest?.id) {
         val request = state.scrollRequest ?: return@LaunchedEffect
         val targetKey = state.pages.getOrNull(request.itemIndex)?.key ?: return@LaunchedEffect
@@ -749,19 +771,24 @@ private fun HorizontalMangaPager(
         }.distinctUntilChanged()
             .collect { entry ->
                 val (isScrolling, indices, itemIndex) = entry ?: return@collect
-                if (isScrolling) return@collect
                 itemIndex?.let { index ->
                     when (val item = state.pages.getOrNull(index)) {
-                        is MangaReaderItemUi.Page -> onIntent(MangaReaderIntent.VisibleItemChanged(
-                            itemIndex = index,
-                            firstItemIndex = indices.first(),
-                            lastItemIndex = indices.last(),
-                            currentChapterVisible = indices.any { visibleIndex ->
-                                (state.pages.getOrNull(visibleIndex) as? MangaReaderItemUi.Page)
-                                    ?.chapterIndex == state.chapterIndex
-                            },
-                            navigationId = state.navigationId,
-                        ))
+                        is MangaReaderItemUi.Page -> if (isScrolling) {
+                            onIntent(MangaReaderIntent.FooterItemChanged(index, state.navigationId))
+                        } else {
+                            onIntent(
+                                MangaReaderIntent.VisibleItemChanged(
+                                    itemIndex = index,
+                                    firstItemIndex = indices.first(),
+                                    lastItemIndex = indices.last(),
+                                    currentChapterVisible = indices.any { visibleIndex ->
+                                        (state.pages.getOrNull(visibleIndex) as? MangaReaderItemUi.Page)
+                                            ?.chapterIndex == state.chapterIndex
+                                    },
+                                    navigationId = state.navigationId,
+                                )
+                            )
+                        }
                         is MangaReaderItemUi.ChapterTransition -> Unit
                         is MangaReaderItemUi.ChapterEdge, null -> Unit
                     }
@@ -1010,13 +1037,18 @@ private fun VerticalMangaPager(
             Triple(pagerState.isScrollInProgress, pagerState.currentPage, item)
         }.distinctUntilChanged()
             .collect { (isScrolling, page, item) ->
-                if (isScrolling) return@collect
                 when (item) {
-                    is MangaReaderItemUi.Page -> onIntent(MangaReaderIntent.VisibleItemChanged(
-                        itemIndex = page,
-                        currentChapterVisible = item.chapterIndex == state.chapterIndex,
-                        navigationId = state.navigationId,
-                    ))
+                    is MangaReaderItemUi.Page -> if (isScrolling) {
+                        onIntent(MangaReaderIntent.FooterItemChanged(page, state.navigationId))
+                    } else {
+                        onIntent(
+                            MangaReaderIntent.VisibleItemChanged(
+                                itemIndex = page,
+                                currentChapterVisible = item.chapterIndex == state.chapterIndex,
+                                navigationId = state.navigationId,
+                            )
+                        )
+                    }
                     is MangaReaderItemUi.ChapterTransition -> Unit
                     is MangaReaderItemUi.ChapterEdge, null -> Unit
                 }

+ 84 - 12
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.ContentCopy
 import androidx.compose.material.icons.filled.Download
 import androidx.compose.material.icons.filled.DownloadForOffline
 import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Extension
 import androidx.compose.material.icons.filled.Image
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
@@ -23,6 +24,10 @@ import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.ShoppingCart
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.res.stringResource
@@ -30,6 +35,8 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.book.read.sheet.ReaderBookHeader
 import io.legado.app.ui.book.read.sheet.ReaderBookHeaderState
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.reader.ReaderMenuActionSquare
 
@@ -37,6 +44,7 @@ private data class MangaMoreAction(
     val label: String,
     val icon: ImageVector,
     val intent: MangaReaderIntent,
+    val longClickIntent: MangaReaderIntent? = null,
 )
 
 @Composable
@@ -70,14 +78,50 @@ internal fun MangaReaderSourceActionsSheet(
             )
         }
         Spacer(Modifier.height(16.dp))
-        val actions = listOf(
-            MangaMoreAction(stringResource(R.string.change_origin), Icons.Default.SwapHoriz, MangaReaderIntent.ChangeSource),
-            MangaMoreAction(stringResource(R.string.refresh), Icons.Default.Refresh, MangaReaderIntent.RefreshChapter),
-            MangaMoreAction(stringResource(R.string.login), Icons.AutoMirrored.Filled.Login, MangaReaderIntent.OpenSourceLogin),
-            MangaMoreAction(stringResource(R.string.manga_reader_buy_chapter), Icons.Default.ShoppingCart, MangaReaderIntent.RequestPayCurrentChapter),
-            MangaMoreAction(stringResource(R.string.edit_source), Icons.Default.Edit, MangaReaderIntent.OpenSourceEdit),
-            MangaMoreAction(stringResource(R.string.disable_source), Icons.Default.MoreVert, MangaReaderIntent.DisableCurrentSource),
-        )
+        val actions = buildList {
+            if (state.sourceCustomButtonAvailable) add(
+                MangaMoreAction(
+                    stringResource(R.string.custom_button),
+                    Icons.Default.Extension,
+                    MangaReaderIntent.SourceCustomButton(false),
+                    MangaReaderIntent.SourceCustomButton(true),
+                )
+            )
+            addAll(
+                listOf(
+                    MangaMoreAction(
+                        stringResource(R.string.change_origin),
+                        Icons.Default.SwapHoriz,
+                        MangaReaderIntent.ChangeSource
+                    ),
+                    MangaMoreAction(
+                        stringResource(R.string.refresh),
+                        Icons.Default.Refresh,
+                        MangaReaderIntent.RefreshChapter
+                    ),
+                    MangaMoreAction(
+                        stringResource(R.string.login),
+                        Icons.AutoMirrored.Filled.Login,
+                        MangaReaderIntent.OpenSourceLogin
+                    ),
+                    MangaMoreAction(
+                        stringResource(R.string.manga_reader_buy_chapter),
+                        Icons.Default.ShoppingCart,
+                        MangaReaderIntent.RequestPayCurrentChapter
+                    ),
+                    MangaMoreAction(
+                        stringResource(R.string.edit_source),
+                        Icons.Default.Edit,
+                        MangaReaderIntent.OpenSourceEdit
+                    ),
+                    MangaMoreAction(
+                        stringResource(R.string.disable_source),
+                        Icons.Default.MoreVert,
+                        MangaReaderIntent.DisableCurrentSource
+                    ),
+                )
+            )
+        }
         Column(Modifier
             .fillMaxWidth()
             .padding(horizontal = 16.dp)) {
@@ -87,11 +131,10 @@ internal fun MangaReaderSourceActionsSheet(
                     horizontalArrangement = Arrangement.spacedBy(8.dp),
                 ) {
                     rowActions.forEach { action ->
-                        ReaderMenuActionSquare(
-                            icon = action.icon,
-                            text = action.label,
+                        MangaSourceActionSquare(
+                            action = action,
+                            dispatch = ::dispatch,
                             modifier = Modifier.weight(1f),
-                            onClick = { dispatch(action.intent) },
                         )
                     }
                     repeat(4 - rowActions.size) { Spacer(Modifier.weight(1f)) }
@@ -103,6 +146,35 @@ internal fun MangaReaderSourceActionsSheet(
     }
 }
 
+@Composable
+private fun MangaSourceActionSquare(
+    action: MangaMoreAction,
+    dispatch: (MangaReaderIntent) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    var expanded by remember(action.intent) { mutableStateOf(false) }
+    Box(modifier) {
+        ReaderMenuActionSquare(
+            icon = action.icon,
+            text = action.label,
+            hasMore = action.longClickIntent != null,
+            onClick = { dispatch(action.intent) },
+            onMoreClick = { expanded = true },
+        )
+        RoundDropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false },
+        ) { dismiss ->
+            action.longClickIntent?.let { intent ->
+                RoundDropdownMenuItem(
+                    text = stringResource(R.string.source_custom_button_long_action),
+                    onClick = { dismiss(); dispatch(intent) },
+                )
+            }
+        }
+    }
+}
+
 @Composable
 internal fun MangaReaderPageActionsSheet(
     hasSpread: Boolean,

+ 42 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -20,6 +20,7 @@ import io.legado.app.domain.model.manga.MangaSessionState
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.help.coil.CoverFetcher
+import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
@@ -165,6 +166,25 @@ class MangaReaderViewModel(
                     _effects.tryEmit(MangaReaderEffect.OpenSourceEdit(it))
                 }
             }
+            is MangaReaderIntent.SourceCustomButton -> launchAction {
+                val state = _uiState.value
+                val payload = actionRepository.getSourceCustomButtonPayload(
+                    state.bookUrl,
+                    state.chapterIndex,
+                ) ?: return@launchAction
+                _effects.tryEmit(
+                    MangaReaderEffect.RunSourceCustomButton(
+                        event = if (intent.longClick) {
+                            SourceCallBack.LONG_CLICK_CUSTOM_BUTTON
+                        } else {
+                            SourceCallBack.CLICK_CUSTOM_BUTTON
+                        },
+                        source = payload.source,
+                        book = payload.book,
+                        chapter = payload.chapter,
+                    )
+                )
+            }
             MangaReaderIntent.BackPressed -> {
                 when {
                     _uiState.value.activeDialog != null -> {
@@ -269,6 +289,10 @@ class MangaReaderViewModel(
                 intent.currentChapterVisible,
                 intent.navigationId,
             )
+            is MangaReaderIntent.FooterItemChanged -> updateFooterItem(
+                intent.itemIndex,
+                intent.navigationId,
+            )
             is MangaReaderIntent.PagerScrollChanged -> {
                 pagerScrollInProgress = intent.inProgress
                 if (!intent.inProgress) {
@@ -541,6 +565,9 @@ class MangaReaderViewModel(
 
         refreshContentJob?.cancel()
         refreshContentJob = viewModelScope.launch {
+            val sourceCustomButtonAvailable = actionRepository
+                .refreshSource(book.sourceOrigin)
+                ?.customButton == true
             val previousItems = makePreviousItems(session.previousChapter)
             val nextItems = makeNextItems(session.nextChapter)
             val items = (previousItems + chapterItems(current.chapter) + nextItems).toImmutableList()
@@ -587,12 +614,14 @@ class MangaReaderViewModel(
                     sourceName = book.sourceName,
                     sourceUrl = book.sourceOrigin,
                     sourceType = book.sourceType,
+                    sourceCustomButtonAvailable = sourceCustomButtonAvailable,
                     inBookshelf = book.inBookshelf,
                     pages = items,
                     navigationId = if (old.pages.map { it.key } != items.map { it.key }) {
                         System.nanoTime()
                     } else old.navigationId,
                     currentItemIndex = targetIndex,
+                    footerItemIndex = null,
                     currentPage = if (positionChanged) session.pageIndex else old.currentPage,
                     pageCount = if (positionChanged) current.chapter.pages.size else old.pageCount,
                     chapterIndex = session.chapterIndex,
@@ -1165,6 +1194,7 @@ class MangaReaderViewModel(
         _uiState.update {
             it.copy(
                 currentItemIndex = itemIndex,
+                footerItemIndex = null,
                 currentPage = item.pageIndex,
                 pageCount = item.pageCount,
                 scrollRequest = it.scrollRequest?.takeUnless { request ->
@@ -1178,6 +1208,18 @@ class MangaReaderViewModel(
         }
     }
 
+    private fun updateFooterItem(itemIndex: Int, navigationId: Long) {
+        _uiState.update { state ->
+            if (navigationId != state.navigationId ||
+                state.pages.getOrNull(itemIndex) !is MangaReaderItemUi.Page
+            ) {
+                state
+            } else {
+                state.copy(footerItemIndex = itemIndex)
+            }
+        }
+    }
+
     private fun updatePageLoadState(key: String, loadState: MangaPageLoadState) {
         _uiState.update { state ->
             val index = state.pages.indexOfFirst { it.key == key }

+ 18 - 0
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -13,11 +13,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.constant.AppConst
+import io.legado.app.constant.BookType
+import io.legado.app.model.SourceCallBack
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.READER_RESULT_DELETED
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
+import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
@@ -61,6 +64,12 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
             }
         }
 
+    private val tocActivity = registerForActivityResult(TocActivityResult()) { result ->
+        result?.let { (chapterIndex, chapterPos, _) ->
+            readerViewModel.onIntent(MangaReaderIntent.OpenChapter(chapterIndex, chapterPos))
+        }
+    }
+
     override fun onCreate(savedInstanceState: Bundle?) {
         isRestoredFromSavedState = savedInstanceState != null
         super.onCreate(savedInstanceState)
@@ -91,6 +100,7 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
                     readerViewModel.onIntent(MangaReaderIntent.DismissSheet)
                     openBookInfoActivity()
                 },
+                onOpenFullToc = { tocActivity.launch(state.bookUrl) },
             )
         }
     }
@@ -113,6 +123,14 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
             is MangaReaderEffect.OpenSourceEdit -> sourceEditActivity.launch(
                 MainActivity.createBookSourceEditIntent(this, effect.sourceUrl)
             )
+            is MangaReaderEffect.RunSourceCustomButton -> SourceCallBack.callBackBtn(
+                this,
+                effect.event,
+                effect.source,
+                effect.book,
+                effect.chapter,
+                BookType.image,
+            )
             is MangaReaderEffect.OpenPaymentUrl -> startActivity(
                 MainActivity.createWebViewIntent(
                     this,

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

@@ -422,7 +422,7 @@ internal val ReadBookButtonIds = listOf(
 )
 
 internal val MoreActionIds = listOf(
-    "change_source", "refresh", "download", "edit_content", "add_bookmark",
+    "source_custom_button", "change_source", "refresh", "download", "edit_content", "add_bookmark",
     "text_processing", "reverse_content", "re_segment",
     "del_ruby", "del_h", "toc_rule", "charset", "image_style", "page_anim",
     "simulated_reading", "get_progress", "cover_progress", "highlight_rule",

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

@@ -54,8 +54,8 @@ import androidx.compose.material.icons.outlined.DownloadForOffline
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Stable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -132,6 +132,7 @@ fun ReaderBookSheetRoute(
     onChapterClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
     currentChapterIndex: Int? = null,
     onOpenFullBookInfo: () -> Unit,
+    onOpenFullToc: (() -> Unit)? = null,
     /** 书签页跳转:携带完整书签供跳转前校验。 */
     onBookmarkNavigate: (Bookmark) -> Unit = { _ -> },
     /** 笔记页跳转:携带完整展示项供跳转前校验。 */
@@ -203,15 +204,23 @@ fun ReaderBookSheetRoute(
         onOpenFullScreen = { tab ->
             when (tab) {
                 ReaderBookSheetTab.Information -> onOpenFullBookInfo()
-                ReaderBookSheetTab.Toc,
+                ReaderBookSheetTab.Toc -> {
+                    if (onOpenFullToc != null) {
+                        onOpenFullToc()
+                    } else {
+                        fullTocLauncher.launch(
+                            Intent(context, TocActivity::class.java)
+                                .putExtra("bookUrl", bookUrl)
+                                .putExtra("initialPage", 0)
+                        )
+                    }
+                }
+
                 ReaderBookSheetTab.Bookmarks -> {
                     fullTocLauncher.launch(
                         Intent(context, TocActivity::class.java)
                             .putExtra("bookUrl", bookUrl)
-                            .putExtra(
-                                "initialPage",
-                                if (tab == ReaderBookSheetTab.Bookmarks) 1 else 0,
-                            )
+                            .putExtra("initialPage", 1)
                     )
                 }
 

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

@@ -1,22 +1,18 @@
 package io.legado.app.ui.book.read.sheet
 
-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.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Toc
 import androidx.compose.material.icons.filled.Animation
 import androidx.compose.material.icons.filled.AutoStories
 import androidx.compose.material.icons.filled.Bookmark
@@ -24,7 +20,7 @@ import androidx.compose.material.icons.filled.BugReport
 import androidx.compose.material.icons.filled.CleanHands
 import androidx.compose.material.icons.filled.CloudDownload
 import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Extension
 import androidx.compose.material.icons.filled.FindReplace
 import androidx.compose.material.icons.filled.Image
 import androidx.compose.material.icons.filled.Refresh
@@ -32,23 +28,16 @@ import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.SwapVert
 import androidx.compose.material.icons.filled.Sync
-import androidx.compose.material.icons.automirrored.filled.Toc
 import androidx.compose.material.icons.filled.Translate
 import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -59,15 +48,12 @@ import io.legado.app.ui.book.read.ReadBookButtonConfigItem
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookSheet
 import io.legado.app.ui.book.read.ReadBookUiState
-import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.ConfigListEntry
 import io.legado.app.ui.widget.components.ReorderableConfigList
-import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.reader.ReaderMenuActionSquare
-import io.legado.app.ui.widget.components.text.AppText
 
 private data class MoreActionSpec(
     val id: String,
@@ -198,7 +184,8 @@ private fun ActionSquareHost(
     modifier: Modifier = Modifier,
 ) {
     var expanded by remember(action.id) { mutableStateOf(false) }
-    val hasMore = action.id == "change_source" || action.id == "refresh"
+    val hasMore = action.id == "change_source" || action.id == "refresh" ||
+            action.id == "source_custom_button"
     Box(modifier = modifier) {
         ReaderMenuActionSquare(
             icon = action.icon,
@@ -213,6 +200,19 @@ private fun ActionSquareHost(
             onMoreClick = { expanded = true },
         )
         when (action.id) {
+            "source_custom_button" -> RoundDropdownMenu(
+                expanded = expanded,
+                onDismissRequest = { expanded = false },
+            ) { dismiss ->
+                RoundDropdownMenuItem(
+                    text = stringResource(R.string.source_custom_button_long_action),
+                    onClick = {
+                        dismiss()
+                        dispatch(ReadBookIntent.SourceCustomButton(true))
+                    },
+                )
+            }
+
             "change_source" -> RoundDropdownMenu(
                 expanded = expanded,
                 onDismissRequest = { expanded = false },
@@ -290,6 +290,11 @@ private fun moreActionSpecs(
     onIntent: (ReadBookIntent) -> Unit,
     dispatch: (ReadBookIntent) -> Unit,
 ): List<MoreActionSpec> = listOf(
+    MoreActionSpec(
+        "source_custom_button", stringResource(R.string.custom_button), Icons.Default.Extension,
+        applicable = state.bookSource?.customButton == true,
+        onClick = { dispatch(ReadBookIntent.SourceCustomButton(false)) },
+    ),
     MoreActionSpec(
         "change_source", stringResource(R.string.change_origin), Icons.Default.SwapHoriz,
         applicable = !state.isLocalBook, onClick = { dispatch(ReadBookIntent.MenuChangeSource) }),

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

@@ -150,7 +150,7 @@ class ReadAloudPlayerCoordinator(
             chapterTitle = chapter?.title.orEmpty(),
             chapterText = input?.source?.semanticContent.orEmpty(),
             textLines = chapter?.paragraphs.orEmpty().mapNotNull { paragraph ->
-                paragraph.text.replace(Regex("[袮祢꧁]"), " ").trim()
+                paragraph.text.replace(Regex("[袮祢꧁\uFFFC]"), " ").trim()
                     .takeIf(String::isNotEmpty)?.let {
                     ReadAloudTextLineUi(it, paragraph.chapterPosition)
                 }

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

@@ -2,7 +2,6 @@ package io.legado.app.ui.book.readaloud.player
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
@@ -10,9 +9,7 @@ import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
@@ -34,14 +31,11 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.FastRewind
-import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.SkipNext
 import androidx.compose.material.icons.filled.SkipPrevious
@@ -93,7 +87,6 @@ import io.legado.app.ui.widget.components.button.series.SmallAnimatedButton
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
 import io.legado.app.ui.widget.components.player.PlayerBackground
@@ -122,13 +115,7 @@ fun ReadAloudPlayerScreenContent(
     onIntent: (ReadAloudPlayerIntent) -> Unit,
     onBack: () -> Unit,
 ) {
-    val horizontalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
-    val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
-    val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
-        state = verticalPagerState,
-        orientation = Orientation.Vertical,
-    )
-    val coroutineScope = rememberCoroutineScope()
+    val horizontalPagerState = rememberPagerState(initialPage = 1, pageCount = { 3 })
     var isTextPageUserScrolling by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
@@ -147,7 +134,7 @@ fun ReadAloudPlayerScreenContent(
         Modifier
     }
     LaunchedEffect(horizontalPagerState.currentPage) {
-        if (horizontalPagerState.currentPage != 1) isTextPageUserScrolling = false
+        if (horizontalPagerState.currentPage != 2) isTextPageUserScrolling = false
     }
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
@@ -313,46 +300,6 @@ fun ReadAloudPlayerScreenContent(
                             .padding(vertical = 16.dp, horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceEvenly,
                     ) {
-                        SmallAnimatedButton(
-                            containerColor = Color.Transparent,
-                            checked = false,
-                            icon = Icons.AutoMirrored.Filled.FormatListBulleted,
-                            iconChecked = Icons.Default.KeyboardArrowUp,
-                            text = stringResource(
-                                if (verticalPagerState.currentPage == 0) {
-                                    R.string.chapter_list
-                                } else {
-                                    R.string.back
-                                }
-                            ),
-                            contentDescription = stringResource(
-                                if (verticalPagerState.currentPage == 0) {
-                                    R.string.chapter_list
-                                } else {
-                                    R.string.back
-                                }
-                            ),
-                            onCheckedChange = {
-                                coroutineScope.launch {
-                                    if (horizontalPagerState.currentPage != 0) {
-                                        horizontalPagerState.animateScrollToPage(
-                                            page = 0,
-                                            animationSpec = tween(
-                                                durationMillis = 520,
-                                                easing = FastOutSlowInEasing,
-                                            ),
-                                        )
-                                    }
-                                    verticalPagerState.animateScrollToPage(
-                                        page = if (verticalPagerState.currentPage == 0) 1 else 0,
-                                        animationSpec = tween(
-                                            durationMillis = 520,
-                                            easing = FastOutSlowInEasing,
-                                        ),
-                                    )
-                                }
-                            },
-                        )
                         SmallAnimatedButton(
                             containerColor = Color.Transparent,
                             checked = false,
@@ -415,29 +362,17 @@ fun ReadAloudPlayerScreenContent(
                 modifier = Modifier.fillMaxSize(),
                 verticalAlignment = Alignment.CenterVertically,
             ) { page ->
-                if (page == 0) {
-                    VerticalPager(
-                        state = verticalPagerState,
-                        modifier = Modifier.fillMaxSize(),
-                        horizontalAlignment = Alignment.CenterHorizontally,
-                        pageNestedScrollConnection = verticalPagerNestedScrollConnection,
-                    ) { verticalPage ->
-                        if (verticalPage == 0) {
-                            CoverPage(state, pageContentPadding, flowingTextModifier)
-                        } else {
-                            PlayerTocPage(
-                                chapters = state.chapters,
-                                currentIndex = state.chapterIndex,
-                                isPaused = state.isPaused,
-                                onSelect = {
-                                    onIntent(ReadAloudPlayerIntent.SelectChapter(it))
-                                },
-                                contentPadding = pageContentPadding,
-                            )
-                        }
-                    }
-                } else {
-                    ChapterTextPage(
+                when (page) {
+                    0 -> PlayerTocPage(
+                        chapters = state.chapters,
+                        currentIndex = state.chapterIndex,
+                        isPaused = state.isPaused,
+                        onSelect = { onIntent(ReadAloudPlayerIntent.SelectChapter(it)) },
+                        contentPadding = pageContentPadding,
+                    )
+
+                    1 -> CoverPage(state, pageContentPadding, flowingTextModifier)
+                    else -> ChapterTextPage(
                         state = state,
                         contentPadding = pageContentPadding,
                         flowingTextModifier = flowingTextModifier,
@@ -665,24 +600,17 @@ private fun ChapterTextPage(
                     contentType = { _, _ -> "read_aloud_text_line" },
                 ) { index, line ->
                     val active = index == state.activeTextLine
-                    val backgroundColor by animateColorAsState(
-                        targetValue = if (active) LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) else LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0f),
-                        animationSpec = tween(durationMillis = 400),
-                        label = "active_line_background"
-                    )
                     AppText(
                         text = line.text,
                         modifier = Modifier
                             .fillMaxWidth()
                             .clip(RoundedCornerShape(16.dp))
                             .clickable { onIntent(ReadAloudPlayerIntent.SeekTo(line.chapterPosition)) }
-                            .background(backgroundColor)
                             .padding(horizontal = 12.dp, vertical = 12.dp)
                             .then(flowingTextModifier),
-                        style = if (active) LegadoTheme.typography.bodyLargeEmphasized else LegadoTheme.typography.bodyLarge,
-                        color = if (active) LegadoTheme.colorScheme.onPrimaryContainer
-                        else LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
-                        lineHeight = LegadoTheme.typography.bodyLarge.lineHeight,
+                        style = LegadoTheme.typography.titleLargeEmphasized,
+                        color = if (active) LegadoTheme.colorScheme.onSurface
+                        else LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
                     )
                 }
             }

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

@@ -1834,6 +1834,7 @@
     <string name="text_typeface">字体样式</string>
     <string name="shadow_typeface">阴影样式</string>
     <string name="long_click_to_custom">长按自定义</string>
+    <string name="source_custom_button_long_action">长按操作</string>
     <string name="disable_manga_scroll_animation">禁用翻页动画</string>
     <string name="manga_cross_fade">禁用加载淡入动画</string>
     <string name="text_shadow_set">阴影设置</string>

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

@@ -1988,6 +1988,7 @@
     <string name="locate_current_reading">跳轉到目前閱讀位置</string>
     <string name="login_error">登錄失敗</string>
     <string name="long_click_to_custom">長按自訂</string>
+    <string name="source_custom_button_long_action">長按操作</string>
     <string name="long_press_group">長按分組</string>
     <string name="manage_tag_colors">管理標籤顏色</string>
     <string name="manga">漫畫</string>

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

@@ -1984,6 +1984,7 @@
     <string name="text_typeface">字體樣式</string>
     <string name="shadow_typeface">陰影樣式</string>
     <string name="long_click_to_custom">長按自訂</string>
+    <string name="source_custom_button_long_action">長按操作</string>
     <string name="disable_manga_scroll_animation">停用翻頁動畫</string>
     <string name="manga_cross_fade">停用載入淡入動畫</string>
     <string name="text_shadow_set">陰影設定</string>

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

@@ -1891,6 +1891,7 @@
     <string name="text_typeface">Font Style</string>
     <string name="shadow_typeface">Shadow Style</string>
     <string name="long_click_to_custom">Long Tap to Customize</string>
+    <string name="source_custom_button_long_action">Long-press action</string>
     <string name="disable_manga_scroll_animation">Disable Page Turn Animation</string>
     <string name="manga_cross_fade">Disable Loading Fade-In Animation</string>
     <string name="text_shadow_set">Shadow Settings</string>

+ 2 - 1
app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapterTest.kt

@@ -24,8 +24,9 @@ class ReaderReadAloudChapterTest {
 
     @Test
     fun canonicalSpeechParagraphsUseDisplayedTextPositions() {
-        val chapter = ReaderReadAloudChapter.create(0, "", "袮甲\n乙\n", listOf(0))
+        val chapter = ReaderReadAloudChapter.create(0, "", "袮甲\n乙\uFFFC\n", listOf(0))
         assertEquals(" 甲", chapter.canonicalSpeechParagraphs()[0].text)
+        assertEquals("乙 ", chapter.canonicalSpeechParagraphs()[1].text)
         assertEquals(3, chapter.canonicalSpeechParagraphs()[1].chapterPosition)
     }
 }