HapeLee 2 месяцев назад
Родитель
Сommit
f73572c305

+ 83 - 39
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -150,6 +150,8 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
 import kotlinx.coroutines.withContext
 import kotlin.math.PI
 import kotlin.math.abs
@@ -178,6 +180,7 @@ fun ReaderCanvasSurface(
     modifier: Modifier = Modifier,
     onPreviousPage: () -> ReaderPageWindow?,
     onNextPage: () -> ReaderPageWindow?,
+    onPageBoundaryReached: (ReaderTurnDirection) -> Unit,
     onToggleMenu: () -> Unit,
     onToggleBookmark: () -> Unit,
     swipeToBookmarkEnabled: Boolean,
@@ -239,6 +242,7 @@ fun ReaderCanvasSurface(
     var pendingTurnOrigin by remember { mutableStateOf<ReaderPageId?>(null) }
     val latestPreviousPage by rememberUpdatedState(onPreviousPage)
     val latestNextPage by rememberUpdatedState(onNextPage)
+    val latestPageBoundaryReached by rememberUpdatedState(onPageBoundaryReached)
     val latestAutoPageStop by rememberUpdatedState(onAutoPageStop)
     val latestAutoPagePaused by rememberUpdatedState(autoPagePaused)
     val latestAutoPageActive by rememberUpdatedState(autoPageActive)
@@ -316,14 +320,17 @@ fun ReaderCanvasSurface(
         pages.current.revision,
         pages.next?.id,
         pages.next?.revision,
+        pages.nextPlus?.id,
+        pages.nextPlus?.revision,
     ) {
         // Paged modes do not compose the adjacent page until a gesture starts. Warm its images
         // while the window is idle so the first animation frame never falls back to placeholders.
-        listOfNotNull(pages.nextPlus, pages.next, pages.previous, pages.current)
+        val prefetchSemaphore = Semaphore(2)
+        listOfNotNull(pages.current, pages.next, pages.previous, pages.nextPlus)
             .asSequence()
             .flatMap { page -> page.elements.asSequence().filterIsInstance<ReaderElement.Image>() }
             .distinctBy { element -> element.source to element.bounds }
-            .forEach { element -> launch { loadImage(element) } }
+            .forEach { element -> launch { prefetchSemaphore.withPermit { loadImage(element) } } }
     }
     val transforms = transition.copy(offsetPx = displayOffset).transforms(transitionMode)
     fun pageViewportLayout(window: ReaderPageWindow = currentPageWindow()): ReaderPageViewportLayout =
@@ -438,7 +445,10 @@ fun ReaderCanvasSurface(
     }
     fun tapPageTurn(direction: ReaderTurnDirection) {
         val window = latestPages
-        if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) return
+        if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) {
+            latestPageBoundaryReached(direction)
+            return
+        }
         val width = window.current?.widthPx?.toFloat() ?: return
         if (transitionMode == ReaderTransitionMode.SIMULATION) {
             curlRevealProgress = 1f
@@ -497,18 +507,17 @@ fun ReaderCanvasSurface(
         if (steps == 1) {
             val window = currentPageWindow()
             val currentPage = window.current ?: return
-            applyScrollResult(
-                ReaderScrollPolicy.apply(
-                    scrollOffset,
-                    distance,
-                    window.previous?.scrollExtentPx ?: 0f,
-                    currentPage.scrollExtentPx,
-                    currentPage.scrollViewportExtentPx(),
-                    window.previous != null,
-                    window.next != null,
-                ),
-                window,
+            val result = ReaderScrollPolicy.apply(
+                scrollOffset,
+                distance,
+                window.previous?.scrollExtentPx ?: 0f,
+                currentPage.scrollExtentPx,
+                currentPage.scrollViewportExtentPx(),
+                window.previous != null,
+                window.next != null,
             )
+            applyScrollResult(result, window)
+            if (result.hitBoundary) latestPageBoundaryReached(direction)
             return
         }
         pageMotionJob = animationScope.launch {
@@ -525,7 +534,10 @@ fun ReaderCanvasSurface(
                     window.next != null,
                 )
                 applyScrollResult(result, window)
-                if (result.hitBoundary) return@launch
+                if (result.hitBoundary) {
+                    latestPageBoundaryReached(direction)
+                    return@launch
+                }
                 // 按帧驱动步进:跟随合成器节拍,掉帧时步长自动摊平,不与显示帧脱节。
                 withFrameNanos { }
             }
@@ -771,6 +783,7 @@ fun ReaderCanvasSurface(
                 var bookmarkDrag = false
                 var bookmarkReleased = false
                 var scrollDrag = false
+                var scrollHitBoundary: ReaderTurnDirection? = null
                 var movedPastSlop = false
                 var longPressed = false
                 var grabbingStart = false
@@ -1010,6 +1023,13 @@ fun ReaderCanvasSurface(
                                     window.next != null
                                 )
                                 applyScrollResult(result, window)
+                                if (result.hitBoundary) {
+                                    scrollHitBoundary = if (change.positionChange().y > 0f) {
+                                        ReaderTurnDirection.PREVIOUS
+                                    } else {
+                                        ReaderTurnDirection.NEXT
+                                    }
+                                }
                                 change.consume()
                             }
                         }
@@ -1065,29 +1085,40 @@ fun ReaderCanvasSurface(
                         ) { bookmarkOffset = value }
                     }
                 } else if (scrollDrag) {
-                    val velocity = if (released) velocityTracker.calculateVelocity().y else 0f
-                    pageMotionJob = animationScope.launch {
-                        var lastValue = 0f
-                        try {
-                            Animatable(0f).animateDecay(velocity, scrollDecay) {
-                                val delta = value - lastValue
-                                lastValue = value
-                                val window = currentPageWindow()
-                                val page = window.current ?: return@animateDecay
-                                val result = ReaderScrollPolicy.apply(
-                                    scrollOffset,
-                                    delta,
-                                    window.previous?.scrollExtentPx ?: 0f,
-                                    page.scrollExtentPx,
-                                    page.scrollViewportExtentPx(),
-                                    window.previous != null,
-                                    window.next != null,
-                                )
-                                applyScrollResult(result, window)
-                                if (result.hitBoundary) throw ReaderScrollBoundaryReached()
+                    val boundary = scrollHitBoundary
+                    if (boundary != null) {
+                        latestPageBoundaryReached(boundary)
+                    } else {
+                        val velocity = if (released) velocityTracker.calculateVelocity().y else 0f
+                        pageMotionJob = animationScope.launch {
+                            var lastValue = 0f
+                            try {
+                                Animatable(0f).animateDecay(velocity, scrollDecay) {
+                                    val delta = value - lastValue
+                                    lastValue = value
+                                    val window = currentPageWindow()
+                                    val page = window.current ?: return@animateDecay
+                                    val result = ReaderScrollPolicy.apply(
+                                        scrollOffset,
+                                        delta,
+                                        window.previous?.scrollExtentPx ?: 0f,
+                                        page.scrollExtentPx,
+                                        page.scrollViewportExtentPx(),
+                                        window.previous != null,
+                                        window.next != null,
+                                    )
+                                    applyScrollResult(result, window)
+                                    if (result.hitBoundary) throw ReaderScrollBoundaryReached()
+                                }
+                            } catch (_: ReaderScrollBoundaryReached) {
+                                // Reaching the first/last content boundary ends the fling immediately.
+                                val direction = if (velocity > 0f) {
+                                    ReaderTurnDirection.PREVIOUS
+                                } else {
+                                    ReaderTurnDirection.NEXT
+                                }
+                                latestPageBoundaryReached(direction)
                             }
-                        } catch (_: ReaderScrollBoundaryReached) {
-                            // Reaching the first/last content boundary ends the fling immediately.
                         }
                     }
                 } else if (horizontalTurn && transition.dragging) {
@@ -1101,6 +1132,8 @@ fun ReaderCanvasSurface(
                             lastDragDeltaPx = if (fade) null else lastHorizontalDelta,
                         )
                     )
+                } else if (released && horizontalTurn) {
+                    transition.direction?.let(latestPageBoundaryReached)
                 } else if (released && !suppressTap && total.getDistance() < pageTouchSlop) {
                     // 元素命中复用 DOWN 时刻的布局:与长按同一坐标系,且不被
                     // 松手前可能发生的窗口替换干扰。
@@ -1145,7 +1178,7 @@ fun ReaderCanvasSurface(
                     loadImage = loadImage,
                 )
             }
-        } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.direction != null) {
+        } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.dragging) {
             SimulationPageStack(
                 pages,
                 transition.direction!!,
@@ -1571,7 +1604,18 @@ private fun SimulationPageStack(
                     values[Matrix.TranslateX] = frame.mirror.translateX
                     values[Matrix.TranslateY] = frame.mirror.translateY
                 }
-                withTransform({ transform(matrix) }) { drawLayer(baseLayer) }
+                // Canvas.drawBitmap() in the View implementation naturally kept sampling
+                // within the screenshot's bounds. A transformed GraphicsLayer otherwise
+                // samples beyond its recorded page surface as opaque black on some devices,
+                // producing a dark wedge between the two sides of a curl. Clip in source
+                // coordinates first so uncovered back-page pixels retain the mean background
+                // color drawn above, while the complete background image remains mirrored.
+                withTransform({
+                    transform(matrix)
+                    clipRect(0f, 0f, size.width, size.height)
+                }) {
+                    drawLayer(baseLayer)
+                }
                 drawCurlFolderShadow(frame)
             } }
             drawCurlFrontShadows(frame, paths)

+ 14 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPageColorTransform.kt

@@ -59,6 +59,20 @@ fun ReaderPage.remapThemeColors(change: ReaderThemeColorChange, revisionSalt: Lo
         emphasisUnderlineStyle = emphasisUnderlineStyle?.copy(
             colorArgb = emphasisUnderlineStyle.colorArgb.mapped(change.oldBodyArgb, change.newBodyArgb),
         ),
+        // Page-tip glyphs (battery and chapter-arrow variants) are painted with their row's
+        // color. The View reader updated them through PageView.upThemeColors(); keep cached
+        // Canvas pages in sync without waiting for the next pagination pass.
+        decoration = decoration.copy(
+            header = decoration.header?.remapThemeColor(change),
+            footer = decoration.footer?.remapThemeColor(change),
+        ),
         revision = revision xor revisionSalt,
     )
 }
+
+private fun ReaderTipRow.remapThemeColor(change: ReaderThemeColorChange): ReaderTipRow = copy(
+    colorArgb = if (colorArgb == change.oldBodyArgb) change.newBodyArgb else colorArgb,
+    dividerColorArgb = dividerColorArgb?.let { color ->
+        if (color == change.oldBodyArgb) change.newBodyArgb else color
+    },
+)

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

@@ -1040,6 +1040,9 @@ sealed interface ConfigUpdateAction {
     data object UpdateStyle : ConfigUpdateAction
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
+
+    /** Re-fetch inline images after a reading-theme preset changes its text colors. */
+    data object RefreshInlineImages : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
     data object RelayoutContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
@@ -1221,6 +1224,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.RefreshInlineImages,
             ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent,
             ConfigUpdateAction.UpdateSystemUi,

+ 101 - 17
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -88,11 +88,13 @@ import io.legado.app.utils.share
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.async
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -142,6 +144,7 @@ class ReadBookController(
     }
     private val readerImageLoads = ConcurrentHashMap<String, Deferred<android.graphics.Bitmap?>>()
     private val readerImageGenerations = ConcurrentHashMap<String, Long>()
+    private var readerImageLoadJob = SupervisorJob()
 
     private fun readerImageCacheKey(element: ReaderElement.Image): String =
         ReaderImageCachePolicy.withGeneration(
@@ -152,27 +155,54 @@ class ReadBookController(
     fun cachedReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? =
         readerImageCache.get(readerImageCacheKey(element))?.takeUnless(android.graphics.Bitmap::isRecycled)
 
-    private fun invalidateReaderImage(source: String) {
-        val generation = readerImageGenerations.compute(source) { _, current -> (current ?: 0L) + 1L } ?: 1L
-        val currentGenerationSuffix = "|generation=$generation"
+    private fun activeReaderImageKeys(window: ReaderPageWindow): Set<String> =
+        listOfNotNull(window.previous, window.current, window.next, window.nextPlus)
+            .asSequence()
+            .flatMap { page -> page.elements.asSequence().filterIsInstance<ReaderElement.Image>() }
+            .map(::readerImageCacheKey)
+            .toSet()
+
+    private fun cancelReaderImageLoadsExcept(allowedKeys: Set<String>) {
+        readerImageLoads.entries
+            .filter { (key, _) -> key !in allowedKeys }
+            .forEach { (key, load) ->
+                if (readerImageLoads.remove(key, load)) load.cancel()
+            }
+    }
+
+    private fun invalidateReaderImages(sources: Set<String>) {
+        if (sources.isEmpty()) return
+        sources.forEach { source ->
+            readerImageGenerations.compute(source) { _, current -> (current ?: 0L) + 1L }
+        }
         readerImageCache.snapshot().keys
-            .filter {
-                ReaderImageCachePolicy.belongsToSource(it, source) &&
-                    !it.endsWith(currentGenerationSuffix)
+            .filter { key ->
+                sources.any { source ->
+                    ReaderImageCachePolicy.belongsToSource(
+                        key,
+                        source
+                    )
+                }
             }
             .forEach(readerImageCache::remove)
         readerImageLoads.entries
-            .filter {
-                ReaderImageCachePolicy.belongsToSource(it.key, source) &&
-                    !it.key.endsWith(currentGenerationSuffix)
+            .filter { (key, _) ->
+                sources.any { source ->
+                    ReaderImageCachePolicy.belongsToSource(
+                        key,
+                        source
+                    )
+                }
             }
             .forEach { (key, load) ->
-            if (readerImageLoads.remove(key, load)) load.cancel()
-        }
+                if (readerImageLoads.remove(key, load)) {
+                    load.cancel()
+                }
+            }
         val revisionSalt = System.nanoTime()
         var changed = false
         directReaderPages = directReaderPages.map { page ->
-            if (page.elements.any { it is ReaderElement.Image && it.source == source }) {
+            if (page.elements.any { it is ReaderElement.Image && it.source in sources }) {
                 changed = true
                 page.copy(revision = page.revision xor revisionSalt)
             } else {
@@ -182,11 +212,39 @@ class ReadBookController(
         if (changed) directReaderPageIndex?.let(::publishDirectReaderWindow)
     }
 
+    private fun invalidateReaderImage(source: String) = invalidateReaderImages(setOf(source))
+
+    private fun refreshInlineImagesThenReload() {
+        val sources = _readerPageWindow.value
+            .let { window ->
+                listOfNotNull(
+                    window.previous,
+                    window.current,
+                    window.next,
+                    window.nextPlus
+                )
+            }
+            .asSequence()
+            .flatMap { page -> page.elements.asSequence() }
+            .filterIsInstance<ReaderElement.Image>()
+            .map(ReaderElement.Image::source)
+            .toSet()
+        if (sources.isEmpty()) {
+            if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+            return
+        }
+        activity.lifecycleScope.launch {
+            viewModel.refreshImageFiles(sources)
+            invalidateReaderImages(sources)
+            if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+        }
+    }
+
     /** Android image capability used by the Compose Canvas renderer. */
     suspend fun loadReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? {
         cachedReaderImage(element)?.let { return it }
         val key = readerImageCacheKey(element)
-        val candidate = activity.lifecycleScope.async(IO, start = CoroutineStart.LAZY) {
+        val candidate = CoroutineScope(readerImageLoadJob + IO).async(start = CoroutineStart.LAZY) {
             readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled) ?: ReadBook.book?.let { book ->
                 ImageProvider.getImage(
                     book = book,
@@ -365,6 +423,11 @@ class ReadBookController(
     }
 
     private fun updateReaderPageWindow(value: ReaderPageWindow): ReaderPageWindow {
+        val previous = _readerPageWindow.value.current
+        val next = value.current
+        if (previous?.id != next?.id || previous?.layoutRevision != next?.layoutRevision) {
+            cancelReaderImageLoadsExcept(activeReaderImageKeys(value))
+        }
         _readerPageWindow.value = value
         readerSessionViewModel.submitPageWindow(value)
         return value
@@ -382,6 +445,10 @@ class ReadBookController(
 
     fun onComposeRendererDetached() {
         ReadBook.unregisterRender(this)
+        cancelReaderImageLoadsExcept(emptySet())
+        readerImageLoadJob.cancel()
+        readerImageLoadJob = SupervisorJob()
+        readerImageCache.evictAll()
     }
 
     fun showComposeTextActionMenu(
@@ -1049,7 +1116,6 @@ class ReadBookController(
         layoutController.viewport.value?.let { viewport ->
             updateComposeReaderBackground(viewport.widthPx, viewport.heightPx)
         }
-        rebuildDirectReaderPages()
         upSystemUiVisibility()
         LogUtils.d(
             "ReadBookTheme",
@@ -1550,6 +1616,8 @@ class ReadBookController(
             // ── Reader-renderer effects ──
             is ReadBookEffect.Finish -> closeReadBook()
             is ReadBookEffect.UpdateReaderConfig -> {
+                val refreshInlineImages =
+                    ConfigUpdateAction.RefreshInlineImages in effect.actions
                 if (ConfigUpdateAction.UpdateBackground in effect.actions) {
                     layoutController.viewport.value?.let { viewport ->
                         updateComposeReaderBackground(viewport.widthPx, viewport.heightPx)
@@ -1572,8 +1640,12 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackground,
                         ConfigUpdateAction.UpdateStyle,
                         ConfigUpdateAction.UpdateBackgroundAlpha,
-                        ConfigUpdateAction.UpdatePageSlopSquare -> Unit
-                        ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+                        ConfigUpdateAction.UpdatePageSlopSquare,
+                        ConfigUpdateAction.RefreshInlineImages -> Unit
+
+                        ConfigUpdateAction.ReloadContent -> if (!refreshInlineImages && viewModel.isInitFinish) {
+                            ReadBook.loadContent(resetPageOffset = false)
+                        }
                         ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
                             layoutController.requestRelayout()
                         }
@@ -1593,7 +1665,8 @@ class ReadBookController(
                         ConfigUpdateAction.UpdatePageAnim -> Unit
                     }
                 }
-                if (effect.actions.any(ConfigUpdateAction::invalidatesDirectReaderPages)) {
+                if (refreshInlineImages) refreshInlineImagesThenReload()
+                if (!refreshInlineImages && effect.actions.any(ConfigUpdateAction::invalidatesDirectReaderPages)) {
                     rebuildDirectReaderPages()
                 }
             }
@@ -2037,6 +2110,16 @@ class ReadBookController(
         return window
     }
 
+    /** Restores the legacy reader's feedback when a page turn reaches a book boundary. */
+    fun showComposePageBoundary(direction: ReaderTurnDirection) {
+        activity.toastOnUi(
+            when (direction) {
+                ReaderTurnDirection.PREVIOUS -> R.string.no_prev_page
+                ReaderTurnDirection.NEXT -> R.string.no_next_page
+            },
+        )
+    }
+
     /**
      * 章节边界而邻章未分页:滚入"加载中"占位页并触发装载(对照 shutiao 的占位页
      * 滚动继续语义)。占位页插入 directReaderPages 并正常发布——装载期间页码、
@@ -2295,6 +2378,7 @@ private fun ConfigUpdateAction.invalidatesDirectReaderPages(): Boolean = when (t
     ConfigUpdateAction.UpdateBackground,
     ConfigUpdateAction.UpdateBackgroundAlpha,
     ConfigUpdateAction.UpdatePageSlopSquare,
+    ConfigUpdateAction.RefreshInlineImages,
     ConfigUpdateAction.RebuildWholeBookPageIndex,
     ConfigUpdateAction.UpdateWholeBookPageDemand,
     ConfigUpdateAction.SubmitRenderTask,

+ 6 - 5
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -101,7 +101,6 @@ import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.onSubscription
 import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 import java.util.concurrent.atomic.AtomicReference
 import kotlin.time.Duration.Companion.minutes
 import kotlin.time.Duration.Companion.seconds
@@ -352,12 +351,13 @@ fun ReadBookRouteScreen(
 
     // ── Effect collection: route handles launcher effects, rest goes to bridge ──
 
-    LaunchedEffect(viewModel) {
-        launch {
+    LaunchedEffect(viewModel, lifecycleOwner) {
+        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
             viewModel.effects
                 .onSubscription {
-                    effectsReady.complete(Unit)
-                    onEffectsReady()
+                    if (effectsReady.complete(Unit)) {
+                        onEffectsReady()
+                    }
                 }
                 .collect { effect ->
                     try {
@@ -718,6 +718,7 @@ fun ReadBookRouteScreen(
                     .layerBackdrop(menuBackdrop),
                 onPreviousPage = { controller.completeComposePageTurn(PageDirection.PREV) },
                 onNextPage = { controller.completeComposePageTurn(PageDirection.NEXT) },
+                    onPageBoundaryReached = controller::showComposePageBoundary,
                 onToggleMenu = controller::showComposeActionMenu,
                 onToggleBookmark = { viewModel.onIntent(ReadBookIntent.ToggleBookmark) },
                 swipeToBookmarkEnabled = readPreferences.swipeToAddBookmark,

+ 25 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -103,6 +103,7 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 private const val READER_SYNC_MIN_INTERVAL_MS = 250L
 
@@ -2354,18 +2355,36 @@ class ReadBookViewModel(
     }
 
     fun refreshImage(src: String) {
+        refreshImages(setOf(src))
+    }
+
+    /**
+     * Re-fetch the supplied inline images. Theme changes use this same path as the
+     * reader's explicit “refresh image” action so source-side JS is evaluated again.
+     */
+    fun refreshImages(sources: Set<String>) {
+        if (sources.isEmpty()) return
         execute {
-            ReadBook.book?.let { book ->
-                val vFile = BookHelp.getImage(book, src)
-                ImageProvider.bitmapLruCache.remove(vFile.absolutePath)
-                vFile.delete()
-            }
+            refreshImageFiles(sources)
         }.onFinally {
-            _effects.tryEmit(ReadBookEffect.InvalidateReaderImage(src))
+            sources.forEach { source ->
+                _effects.tryEmit(ReadBookEffect.InvalidateReaderImage(source))
+            }
             ReadBook.loadContent(false)
         }
     }
 
+    /** Performs the file-cache half of image refresh before a renderer redraws it. */
+    suspend fun refreshImageFiles(sources: Set<String>) = withContext(IO) {
+        val book = ReadBook.book ?: return@withContext
+        sources.forEach { source ->
+            val vFile = BookHelp.getImage(book, source)
+            ImageProvider.bitmapLruCache.remove(vFile.absolutePath)
+            vFile.delete()
+            ImageProvider.cacheImage(book, source, ReadBook.bookSource)
+        }
+    }
+
     fun saveImage(src: String?) {
         src ?: return
         val book = ReadBook.book ?: return

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

@@ -55,6 +55,7 @@ class ReadConfigUpdateDelegate(
 
     fun handle(update: ConfigUpdate) {
         val styleMutation = update.toReadStyleMutation()
+        val deferEffectsUntilStyleSelectionApplied = update is ConfigUpdate.StyleSelect
         styleMutation?.let(readBookStyleConfigRepository::updateCurrentStyle)
         when (update) {
             // --- Text style ---
@@ -114,6 +115,11 @@ class ReadConfigUpdateDelegate(
             is ConfigUpdate.StyleSelect -> {
                 scope.launch {
                     readSettingsRepository.setStyleSelect(ReadSessionState.isComic, update.index)
+                    // The selected style supplies ReadBookConfig's active text color.
+                    // Publish reload only after this write, so source-side image JS
+                    // observes the newly selected reading theme.
+                    host.refreshConfigSnapshots()
+                    host.emitEffect(ReadBookEffect.UpdateReaderConfig(update.actions))
                 }
             }
             is ConfigUpdate.ShareLayout -> {
@@ -753,10 +759,10 @@ class ReadConfigUpdateDelegate(
         // 走了 gateway 的更新由 collectReadStyle 重建快照;只写 DataStore 的更新
         // 不经 gateway,需在此手工重建——且必须两份一起,
         // 否则像 ChineseConverterType 这种本就在 sheetConfig 里的项会一直显示旧值。
-        if (styleMutation == null) {
+        if (styleMutation == null && !deferEffectsUntilStyleSelectionApplied) {
             host.refreshConfigSnapshots()
         }
-        if (update.actions.isNotEmpty()) {
+        if (update.actions.isNotEmpty() && !deferEffectsUntilStyleSelectionApplied) {
             host.emitEffect(ReadBookEffect.UpdateReaderConfig(update.actions))
         }
     }

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

@@ -129,6 +129,7 @@ class ReadStyleDelegate(
             ConfigUpdateAction.UpdateBackgroundAlpha,
             ConfigUpdateAction.UpdateStyle,
             ConfigUpdateAction.UpdateSystemUi,
+            ConfigUpdateAction.RefreshInlineImages,
             ConfigUpdateAction.ReloadContent,
             ConfigUpdateAction.UpdatePageAnim,
         )

+ 39 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderPageColorTransformTest.kt

@@ -101,6 +101,45 @@ class ReaderPageColorTransformTest {
         assertEquals(newBody, (result.elements[2] as ReaderElement.ParagraphMarker).colorArgb)
     }
 
+    @Test
+    fun `theme recolor updates inherited page-tip glyph colors but preserves custom tip colors`() {
+        val oldBody = 0xFF202020.toInt()
+        val newBody = 0xFFE0E0E0.toInt()
+        val custom = 0xFF1177AA.toInt()
+        val inherited = ReaderTipRow(
+            visible = true,
+            tips = emptyList(),
+            colorArgb = oldBody,
+            fontSizePx = 12f,
+            fontPath = "",
+            paddingLeftPx = 0f,
+            paddingTopPx = 0f,
+            paddingRightPx = 0f,
+            paddingBottomPx = 0f,
+            dividerColorArgb = oldBody,
+        )
+        val page = ReaderPage(
+            id = ReaderPageId(0, 0), chapterTitle = "", text = "",
+            widthPx = 100, heightPx = 100, contentTopPx = 0f, contentBottomPx = 100f,
+            elements = emptyList(),
+            decoration = ReaderPageDecoration(
+                header = inherited,
+                footer = inherited.copy(colorArgb = custom)
+            ),
+            revision = 1L,
+        )
+
+        val result = page.remapThemeColors(
+            ReaderThemeColorChange(oldBody, newBody, oldBody, newBody),
+            revisionSalt = 2L,
+        )
+
+        assertEquals(newBody, result.decoration.header?.colorArgb)
+        assertEquals(newBody, result.decoration.header?.dividerColorArgb)
+        assertEquals(custom, result.decoration.footer?.colorArgb)
+        assertEquals(newBody, result.decoration.footer?.dividerColorArgb)
+    }
+
     private fun text(value: String, color: Int, top: Float, emphasized: Boolean = false) = ReaderElement.Text(
         bounds = ReaderRect(0f, top, 50f, top + 20f),
         baselinePx = top + 15f,

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

@@ -1,10 +1,10 @@
 package io.legado.app.feature.reader.core.transition
 
+import io.legado.app.constant.PageAnim
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
-import io.legado.app.constant.PageAnim
 
 class ReaderPageTransitionTest {
     @Test
@@ -93,6 +93,7 @@ class ReaderPageTransitionTest {
         )
 
         assertFalse(transition.dragging)
+        assertEquals(ReaderTurnDirection.PREVIOUS, transition.direction)
         assertEquals(0f, transition.offsetPx)
     }