Przeglądaj źródła

feat: 漫画问题

HapeLee 1 miesiąc temu
rodzic
commit
b45388ab2e

+ 6 - 1
app/src/main/java/io/legado/app/data/repository/manga/DefaultMangaReaderSession.kt

@@ -138,7 +138,12 @@ class DefaultMangaReaderSession(
         cancelScheduledProgressPersist()
         val old = _state.value
         if (old.book != null) {
-            persistProgress()
+            // An external TOC selection has already persisted the requested chapter. Saving this
+            // retained session now would overwrite that authoritative target with the last page
+            // that happened to be visible before navigation returned to the reader.
+            val externallySelectedSameBook = command.chapterChanged &&
+                    command.bookUrl == old.book.bookUrl
+            if (!externallySelectedSameBook) persistProgress()
             if (old.resumed) withContext(ioDispatcher) {
                 dataGateway.pause(old.book.bookUrl, old.book.inBookshelf)
             }

+ 12 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt

@@ -108,6 +108,18 @@ internal fun acceptsMangaVisibleItem(
 internal fun shouldExposeMangaPages(currentChapterFinished: Boolean): Boolean =
     currentChapterFinished
 
+/**
+ * While an explicit catalog/menu navigation is being handed to the session, emissions from the
+ * previous chapter must not reclaim the UI. The session command is ordered asynchronously, so a
+ * presentation or page-state update for the old chapter can otherwise replace the target loading
+ * placeholder before [MangaSessionCommand.OpenChapter] is reduced.
+ */
+internal fun acceptsMangaSessionForExplicitNavigation(
+    pendingExplicitChapterIndex: Int?,
+    sessionChapterIndex: Int,
+): Boolean = pendingExplicitChapterIndex == null ||
+        pendingExplicitChapterIndex == sessionChapterIndex
+
 enum class MangaChapterSwitch { NONE, NEXT, PREVIOUS }
 
 /**

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

@@ -549,7 +549,7 @@ private fun MangaMenuBottomBar(
                         .fillMaxWidth()
                         .padding(vertical = 16.dp)
                     ) {
-                        if (state.pageCount > 1) {
+                        if (state.chapterCount > 0) {
                             Row(
                                 modifier = Modifier
                                     .fillMaxWidth()
@@ -567,9 +567,14 @@ private fun MangaMenuBottomBar(
                                 }
                                 ReadMenuSlider(
                                     value = state.currentPage.toFloat()
-                                        .coerceIn(0f, (state.pageCount - 1).toFloat()),
+                                        .coerceIn(
+                                            0f,
+                                            (state.pageCount - 1).coerceAtLeast(1).toFloat()
+                                        ),
                                     onValueChange = { onIntent(MangaReaderIntent.SeekToPage(it.toInt())) },
-                                    valueRange = 0f..(state.pageCount - 1).toFloat(),
+                                    enabled = state.pageCount > 1,
+                                    valueRange = 0f..(state.pageCount - 1).coerceAtLeast(1)
+                                        .toFloat(),
                                     steps = (state.pageCount - 2).coerceAtLeast(0),
                                     accessibilityLabel = readingPageDescription,
                                     modifier = Modifier.weight(1f),

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -28,6 +28,7 @@ fun MangaReaderRouteScreen(
     bookUrl: String?,
     inBookshelf: Boolean,
     chapterChanged: Boolean,
+    openRequestId: Long,
     viewModel: MangaReaderViewModel,
     restoreSystemBarsVisible: Boolean,
     onFinish: (bookshelfChanged: Boolean) -> Unit,
@@ -47,7 +48,7 @@ fun MangaReaderRouteScreen(
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val networkChangedListener = remember(activity) { NetworkChangedListener(activity) }
 
-    LaunchedEffect(viewModel, bookUrl, inBookshelf, chapterChanged) {
+    LaunchedEffect(viewModel, bookUrl, inBookshelf, chapterChanged, openRequestId) {
         viewModel.onIntent(
             MangaReaderIntent.Initialize(bookUrl, inBookshelf, chapterChanged)
         )

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

@@ -101,6 +101,7 @@ import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import me.saket.telephoto.zoomable.DoubleClickToZoomListener
 import me.saket.telephoto.zoomable.EnabledZoomGestures
@@ -475,7 +476,12 @@ private fun WebtoonMangaList(
 
     fun reportWebtoonVisibleItem(forcedItemIndex: Int? = null) {
         val currentState = latestReaderState
-        val visibleItems = listState.layoutInfo.visibleItemsInfo
+        // Programmatic scrolling also emits a scroll-idle event. That event must not clear the
+        // request before the target's actual layout has been confirmed below.
+        if (forcedItemIndex == null && currentState.scrollRequest != null) return
+        val visibleItems = listState.layoutInfo.visibleItemsInfo.filter { visible ->
+            currentState.pages.getOrNull(visible.index)?.key == visible.key
+        }
         val focusedItemIndex = forcedItemIndex ?: mangaWebtoonFocusedPageIndex(
             items = currentState.pages,
             visibleItemIndices = visibleItems.map { it.index },
@@ -484,6 +490,7 @@ private fun WebtoonMangaList(
         if (currentState.pages.getOrNull(focusedItemIndex) !is MangaReaderItemUi.Page) return
         val forcedItemVisible = forcedItemIndex == null ||
                 visibleItems.any { it.index == forcedItemIndex }
+        if (!forcedItemVisible) return
         val currentChapterVisible = visibleItems.any { visibleItem ->
             (currentState.pages.getOrNull(visibleItem.index) as? MangaReaderItemUi.Page)
                 ?.chapterIndex == currentState.chapterIndex
@@ -524,8 +531,17 @@ private fun WebtoonMangaList(
 
     LaunchedEffect(state.scrollRequest?.id) {
         val request = state.scrollRequest ?: return@LaunchedEffect
+        val targetKey = state.pages.getOrNull(request.itemIndex)?.key ?: return@LaunchedEffect
+        // A placeholder may still be the measured list when the new chapter is composed.
+        snapshotFlow { listState.layoutInfo.totalItemsCount }
+            .first { it == state.pages.size }
         if (request.animated) listState.animateScrollToItem(request.itemIndex)
         else listState.scrollToItem(request.itemIndex)
+        snapshotFlow {
+            listState.layoutInfo.visibleItemsInfo.any {
+                it.index == request.itemIndex && it.key == targetKey
+            }
+        }.first { it }
         reportWebtoonVisibleItem(request.itemIndex)
     }
     LaunchedEffect(
@@ -1630,4 +1646,3 @@ private fun performMangaClickAction(
 internal fun isDoublePageActive(mode: Int, viewport: IntSize): Boolean =
     mode == MangaDoublePageMode.ALWAYS ||
         mode == MangaDoublePageMode.LANDSCAPE && viewport.width > viewport.height
-

+ 19 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -375,6 +375,14 @@ class MangaReaderViewModel(
     fun refreshContent() = refreshContent(readerSession.state.value)
 
     private fun refreshContent(session: MangaSessionState) {
+        // openChapter() updates the target placeholder before its ordered session command runs.
+        // Ignore any old-chapter emissions in that hand-off window; rendering them would make the
+        // reader flash and jump back, and their viewport callback could persist the wrong page.
+        if (!acceptsMangaSessionForExplicitNavigation(
+                pendingExplicitChapterIndex = pendingExplicitChapterIndex,
+                sessionChapterIndex = session.chapterIndex,
+            )
+        ) return
         val book = session.book
         if (book == null) {
             session.openError?.let {
@@ -565,7 +573,8 @@ class MangaReaderViewModel(
             val keyPreserved =
                 oldCurrentItem?.let { old -> items.any { it.key == old.key } } == true
             val targetIndex = anchoredIndex ?: safePosition
-            val positionChanged = shouldPosition || anchoredIndex == null || !keyPreserved
+            val positionChanged = shouldPosition || anchoredIndex == null || !keyPreserved ||
+                    oldState.currentItemIndex != targetIndex
             _uiState.update { old ->
                 old.copy(
                     bookName = book.name,
@@ -580,6 +589,9 @@ class MangaReaderViewModel(
                     sourceType = book.sourceType,
                     inBookshelf = book.inBookshelf,
                     pages = items,
+                    navigationId = if (old.pages.map { it.key } != items.map { it.key }) {
+                        System.nanoTime()
+                    } else old.navigationId,
                     currentItemIndex = targetIndex,
                     currentPage = if (positionChanged) session.pageIndex else old.currentPage,
                     pageCount = if (positionChanged) current.chapter.pages.size else old.pageCount,
@@ -924,6 +936,12 @@ class MangaReaderViewModel(
             pendingExplicitChapterIndex == chapterIndex
         ) return
         pendingExplicitChapterIndex = chapterIndex
+        // A gesture/animation belonging to the old window no longer owns presentation. Its
+        // completion callback may be disposed when the placeholder replaces the page list, so do
+        // not let a stale `true` defer the target chapter indefinitely.
+        pagerScrollInProgress = false
+        deferredReadySession = null
+        refreshContentJob?.cancel()
         showExplicitChapterPlaceholder(chapterIndex, pageIndex, errorMessage = null)
         executeSession(MangaSessionCommand.OpenChapter(chapterIndex, pageIndex))
     }

+ 30 - 19
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -66,7 +66,6 @@ import kotlinx.coroutines.flow.flatMapMerge
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.scan
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
@@ -176,7 +175,7 @@ data class TocDomainItem(
 
 private data class DownloadContext(
     val downloadState: CacheBookDownloadState?,
-    val cachedFiles: Set<String>
+    val cachedChapterIndices: Set<Int>
 )
 
 private data class TocUiConfig(
@@ -273,20 +272,32 @@ class TocViewModel(
                 ""
             )
 
-    private val _cacheFileNames: StateFlow<Set<String>> = bookState.filterNotNull()
-        .map { it.bookUrl }
-        .distinctUntilChanged()
-        .flatMapLatest { url ->
-            val initialFiles = withContext(Dispatchers.IO) {
-                BookHelp.getChapterFiles(bookState.value!!)
-            }.toSet()
-
-            CacheBook.cacheSuccessFlow
-                .filter { it.bookUrl == url }
-                .map { it.getFileName() }
-                .scan(initialFiles) { accumulator, newFileName ->
-                    accumulator + newFileName
-                }
+    private val _cachedChapterIndices: StateFlow<Set<Int>> = bookState.filterNotNull()
+        .flatMapLatest { book ->
+            flow {
+                var cached: Set<Int> = bookRepository.getChapters(book.bookUrl)
+                    .filter { chapter ->
+                        chapter.isVolume || withContext(Dispatchers.IO) {
+                            BookHelp.isChapterCacheComplete(book, chapter)
+                        }
+                    }
+                    .mapTo(mutableSetOf()) { it.index }
+                emit(cached)
+                CacheBook.cacheSuccessFlow
+                    .filter { it.bookUrl == book.bookUrl }
+                    .collect { chapter ->
+                        cached = if (chapter.isVolume ||
+                            withContext(Dispatchers.IO) {
+                                BookHelp.isChapterCacheComplete(book, chapter)
+                            }
+                        ) {
+                            cached + chapter.index
+                        } else {
+                            cached - chapter.index
+                        }
+                        emit(cached)
+                    }
+            }
         }
         .flowOn(Dispatchers.IO)
         .stateIn(
@@ -421,7 +432,7 @@ class TocViewModel(
     private val downloadContextFlow = combine(
         bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(),
         CacheBook.downloadStateFlow,
-        _cacheFileNames
+        _cachedChapterIndices
     ) { bookUrl, state, cached ->
         DownloadContext(state.books[bookUrl], cached)
     }
@@ -493,13 +504,13 @@ class TocViewModel(
 
         val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty()
         val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty()
-        val cachedFiles = downloadCtx.cachedFiles
+        val cachedChapterIndices = downloadCtx.cachedChapterIndices
 
         processedChapters.map { chapter ->
             val downloadState = when {
                 chapter.index in runningIndices -> DownloadState.DOWNLOADING
                 chapter.index in errorIndices -> DownloadState.ERROR
-                chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
+                chapter.index in cachedChapterIndices -> DownloadState.SUCCESS
                 else -> DownloadState.NONE
             }
 

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

@@ -801,6 +801,7 @@ fun MainActivity.mainEntryProvider(
             bookUrl = route.bookUrl,
             inBookshelf = route.inBookshelf,
             chapterChanged = route.chapterChanged,
+            openRequestId = route.openRequestId,
             viewModel = mangaViewModel,
             restoreSystemBarsVisible = configuration.appShell.showStatusBar,
             onFinish = { onNavigateBack() },
@@ -1181,6 +1182,7 @@ fun MainActivity.mainEntryProvider(
                         bookUrl = bookUrl,
                         inBookshelf = inBookshelf,
                         chapterChanged = chapterChanged,
+                        openRequestId = System.nanoTime(),
                     )
                 )
             },

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

@@ -140,6 +140,8 @@ data class MainRouteReadManga(
     val bookUrl: String? = null,
     val inBookshelf: Boolean = true,
     val chapterChanged: Boolean = false,
+    /** Distinguishes repeated open requests for the same book after an external TOC selection. */
+    val openRequestId: Long = 0L,
 ) : MainRoute
 
 @Serializable

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

@@ -458,6 +458,7 @@ object MainNavigator {
                     MainIntent.EXTRA_CHAPTER_CHANGED,
                     false,
                 ) == true,
+                openRequestId = System.nanoTime(),
             )
             MainRouteConst.ROUTE_AUDIO_PLAY -> MainRouteAudioPlay(
                 bookUrl = intent?.getStringExtra(MainIntent.EXTRA_BOOK_URL),

+ 22 - 0
app/src/test/java/io/legado/app/data/repository/manga/DefaultMangaReaderSessionTest.kt

@@ -22,6 +22,28 @@ import org.junit.Test
 @OptIn(ExperimentalCoroutinesApi::class)
 class DefaultMangaReaderSessionTest {
 
+    @Test
+    fun `external toc reopen does not overwrite selected progress with retained session`() =
+        runTest {
+            val gateway = FakeGateway()
+            val dispatcher = StandardTestDispatcher(testScheduler)
+            val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+            session.execute(MangaSessionCommand.Open("book-a", true, false))
+            advanceUntilIdle()
+            gateway.complete("book-a", 0)
+            advanceUntilIdle()
+            session.execute(MangaSessionCommand.VisiblePageChanged(0, 7))
+            advanceUntilIdle()
+            gateway.persisted.clear()
+
+            session.execute(MangaSessionCommand.Open("book-a", true, true))
+            advanceUntilIdle()
+
+            assertTrue(gateway.persisted.isEmpty())
+            session.close()
+        }
+
     @Test
     fun `opening a new book cancels and rejects old chapter results`() = runTest {
         val gateway = FakeGateway()

+ 22 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

@@ -307,6 +307,28 @@ class MangaReaderInteractionTest {
         )
     }
 
+    @Test
+    fun `old session emission cannot reclaim an explicit chapter navigation`() {
+        assertFalse(
+            acceptsMangaSessionForExplicitNavigation(
+                pendingExplicitChapterIndex = 12,
+                sessionChapterIndex = 3,
+            )
+        )
+        assertTrue(
+            acceptsMangaSessionForExplicitNavigation(
+                pendingExplicitChapterIndex = 12,
+                sessionChapterIndex = 12,
+            )
+        )
+        assertTrue(
+            acceptsMangaSessionForExplicitNavigation(
+                pendingExplicitChapterIndex = null,
+                sessionChapterIndex = 3,
+            )
+        )
+    }
+
     @Test
     fun `zoom pan clamps within zoomed content bounds`() {
         val viewport = IntSize(500, 800)

+ 19 - 0
app/src/test/java/io/legado/app/ui/main/MainNavigatorMangaTest.kt

@@ -33,4 +33,23 @@ class MainNavigatorMangaTest {
 
         assertEquals(listOf(MainRouteHome, replacement), backStack)
     }
+
+    @Test
+    fun `repeated external toc selection replaces the same book reader request`() {
+        val oldRequest = MainRouteReadManga(
+            bookUrl = "book-a",
+            chapterChanged = true,
+            openRequestId = 1L,
+        )
+        val newRequest = oldRequest.copy(openRequestId = 2L)
+        val backStack = mutableListOf<NavKey>(
+            MainRouteHome,
+            oldRequest,
+            MainRouteBookInfo("Book A", "Author", "book-a"),
+        )
+
+        MainNavigator.navigateToRoute(backStack, newRequest)
+
+        assertEquals(listOf(MainRouteHome, newRequest), backStack)
+    }
 }