|
@@ -51,6 +51,7 @@ import io.legado.app.feature.reader.legacy.collectLegacyReaderPaginationBatch
|
|
|
import io.legado.app.feature.reader.legacy.failureReasonFor
|
|
import io.legado.app.feature.reader.legacy.failureReasonFor
|
|
|
import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
|
|
import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
|
|
|
import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
|
|
import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
|
|
|
|
|
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
|
|
|
import io.legado.app.help.TTS
|
|
import io.legado.app.help.TTS
|
|
|
import io.legado.app.help.book.isOnLineTxt
|
|
import io.legado.app.help.book.isOnLineTxt
|
|
|
import io.legado.app.help.config.ReadBookConfig
|
|
import io.legado.app.help.config.ReadBookConfig
|
|
@@ -96,6 +97,7 @@ import kotlinx.coroutines.Dispatchers.Main
|
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.Job
|
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.async
|
|
|
|
|
+import kotlinx.coroutines.delay
|
|
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
import kotlinx.coroutines.flow.asSharedFlow
|
|
import kotlinx.coroutines.flow.asSharedFlow
|
|
@@ -145,11 +147,16 @@ class ReadBookController(
|
|
|
private val readerImageLoads = ConcurrentHashMap<String, Deferred<android.graphics.Bitmap?>>()
|
|
private val readerImageLoads = ConcurrentHashMap<String, Deferred<android.graphics.Bitmap?>>()
|
|
|
private val readerImageGenerations = ConcurrentHashMap<String, Long>()
|
|
private val readerImageGenerations = ConcurrentHashMap<String, Long>()
|
|
|
private var readerImageLoadJob = SupervisorJob()
|
|
private var readerImageLoadJob = SupervisorJob()
|
|
|
|
|
+ private val readerImageRefreshMutex = Mutex()
|
|
|
|
|
+ private var readerChapterInputPublishJob: Job? = null
|
|
|
|
|
|
|
|
- private fun readerImageCacheKey(element: ReaderElement.Image): String =
|
|
|
|
|
|
|
+ private fun readerImageCacheKey(
|
|
|
|
|
+ element: ReaderElement.Image,
|
|
|
|
|
+ generation: Long = readerImageGenerations[element.source] ?: 0L,
|
|
|
|
|
+ ): String =
|
|
|
ReaderImageCachePolicy.withGeneration(
|
|
ReaderImageCachePolicy.withGeneration(
|
|
|
ReaderImageCachePolicy.key(element),
|
|
ReaderImageCachePolicy.key(element),
|
|
|
- readerImageGenerations[element.source] ?: 0L,
|
|
|
|
|
|
|
+ generation,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
fun cachedReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? =
|
|
fun cachedReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? =
|
|
@@ -170,39 +177,53 @@ class ReadBookController(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private fun invalidateReaderImages(sources: Set<String>) {
|
|
|
|
|
- if (sources.isEmpty()) return
|
|
|
|
|
- sources.forEach { source ->
|
|
|
|
|
- readerImageGenerations.compute(source) { _, current -> (current ?: 0L) + 1L }
|
|
|
|
|
- }
|
|
|
|
|
- readerImageCache.snapshot().keys
|
|
|
|
|
- .filter { key ->
|
|
|
|
|
- sources.any { source ->
|
|
|
|
|
- ReaderImageCachePolicy.belongsToSource(
|
|
|
|
|
- key,
|
|
|
|
|
- source
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Mirrors the old View reader's order: load the replacement first, then invalidate the
|
|
|
|
|
+ * affected pages. Publishing a new generation before its bitmaps exist showed a placeholder
|
|
|
|
|
+ * frame; cancelling the previous request could also leave that placeholder stuck.
|
|
|
|
|
+ */
|
|
|
|
|
+ private suspend fun replaceReaderImages(sources: Set<String>) =
|
|
|
|
|
+ readerImageRefreshMutex.withLock {
|
|
|
|
|
+ if (sources.isEmpty()) return@withLock
|
|
|
|
|
+ val targetGenerations = sources.associateWith { source ->
|
|
|
|
|
+ (readerImageGenerations[source] ?: 0L) + 1L
|
|
|
|
|
+ }
|
|
|
|
|
+ val elementsBySource = directReaderPages
|
|
|
|
|
+ .asSequence()
|
|
|
|
|
+ .flatMap { page ->
|
|
|
|
|
+ page.elements.asSequence().filterIsInstance<ReaderElement.Image>()
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
- .forEach(readerImageCache::remove)
|
|
|
|
|
- readerImageLoads.entries
|
|
|
|
|
- .filter { (key, _) ->
|
|
|
|
|
- sources.any { source ->
|
|
|
|
|
- ReaderImageCachePolicy.belongsToSource(
|
|
|
|
|
- key,
|
|
|
|
|
- source
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ .filter { it.source in targetGenerations }
|
|
|
|
|
+ .distinctBy { element ->
|
|
|
|
|
+ "${element.source}|${element.bounds.width.toInt()}|${element.bounds.height.toInt()}"
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
- .forEach { (key, load) ->
|
|
|
|
|
- if (readerImageLoads.remove(key, load)) {
|
|
|
|
|
- load.cancel()
|
|
|
|
|
|
|
+ .groupBy(ReaderElement.Image::source)
|
|
|
|
|
+ val readySources = buildSet {
|
|
|
|
|
+ elementsBySource.forEach { (source, elements) ->
|
|
|
|
|
+ val generation = targetGenerations.getValue(source)
|
|
|
|
|
+ val allLoaded = elements.all { element ->
|
|
|
|
|
+ loadReaderImage(element, readerImageCacheKey(element, generation))
|
|
|
|
|
+ ?.takeUnless(ImageProvider::isErrorBitmap) != null
|
|
|
|
|
+ }
|
|
|
|
|
+ if (allLoaded) {
|
|
|
|
|
+ add(source)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // The target generation has not been published, so retaining its error
|
|
|
|
|
+ // placeholder would make a later refresh reuse it instead of retrying.
|
|
|
|
|
+ elements.forEach { element ->
|
|
|
|
|
+ readerImageCache.remove(readerImageCacheKey(element, generation))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ if (readySources.isEmpty()) return@withLock
|
|
|
|
|
+ readySources.forEach { source ->
|
|
|
|
|
+ readerImageGenerations[source] = targetGenerations.getValue(source)
|
|
|
|
|
+ }
|
|
|
val revisionSalt = System.nanoTime()
|
|
val revisionSalt = System.nanoTime()
|
|
|
var changed = false
|
|
var changed = false
|
|
|
directReaderPages = directReaderPages.map { page ->
|
|
directReaderPages = directReaderPages.map { page ->
|
|
|
- if (page.elements.any { it is ReaderElement.Image && it.source in sources }) {
|
|
|
|
|
|
|
+ if (page.elements.any { it is ReaderElement.Image && it.source in readySources }) {
|
|
|
changed = true
|
|
changed = true
|
|
|
page.copy(revision = page.revision xor revisionSalt)
|
|
page.copy(revision = page.revision xor revisionSalt)
|
|
|
} else {
|
|
} else {
|
|
@@ -212,8 +233,12 @@ class ReadBookController(
|
|
|
if (changed) directReaderPageIndex?.let(::publishDirectReaderWindow)
|
|
if (changed) directReaderPageIndex?.let(::publishDirectReaderWindow)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private fun invalidateReaderImage(source: String) = invalidateReaderImages(setOf(source))
|
|
|
|
|
-
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Theme-style selection path, mirroring the legacy View's `[1, 2, 5]`: re-fetch the inline
|
|
|
|
|
+ * images of the current window, then reload the chapter so the new style's colors and page
|
|
|
|
|
+ * geometry replace the old ones. [replaceReaderImages] publishes the replacement bitmaps
|
|
|
|
|
+ * before the affected pages are invalidated, so no placeholder frame is shown.
|
|
|
|
|
+ */
|
|
|
private fun refreshInlineImagesThenReload() {
|
|
private fun refreshInlineImagesThenReload() {
|
|
|
val sources = _readerPageWindow.value
|
|
val sources = _readerPageWindow.value
|
|
|
.let { window ->
|
|
.let { window ->
|
|
@@ -229,21 +254,25 @@ class ReadBookController(
|
|
|
.filterIsInstance<ReaderElement.Image>()
|
|
.filterIsInstance<ReaderElement.Image>()
|
|
|
.map(ReaderElement.Image::source)
|
|
.map(ReaderElement.Image::source)
|
|
|
.toSet()
|
|
.toSet()
|
|
|
- if (sources.isEmpty()) {
|
|
|
|
|
- if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
activity.lifecycleScope.launch {
|
|
activity.lifecycleScope.launch {
|
|
|
- viewModel.refreshImageFiles(sources)
|
|
|
|
|
- invalidateReaderImages(sources)
|
|
|
|
|
|
|
+ if (sources.isNotEmpty()) {
|
|
|
|
|
+ replaceReaderImages(viewModel.refreshImageFiles(sources))
|
|
|
|
|
+ }
|
|
|
if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
|
|
if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Android image capability used by the Compose Canvas renderer. */
|
|
/** Android image capability used by the Compose Canvas renderer. */
|
|
|
suspend fun loadReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? {
|
|
suspend fun loadReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? {
|
|
|
- cachedReaderImage(element)?.let { return it }
|
|
|
|
|
- val key = readerImageCacheKey(element)
|
|
|
|
|
|
|
+ return loadReaderImage(element, readerImageCacheKey(element))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private suspend fun loadReaderImage(
|
|
|
|
|
+ element: ReaderElement.Image,
|
|
|
|
|
+ key: String,
|
|
|
|
|
+ ): android.graphics.Bitmap? {
|
|
|
|
|
+ readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled)
|
|
|
|
|
+ ?.let { return it }
|
|
|
val candidate = CoroutineScope(readerImageLoadJob + IO).async(start = CoroutineStart.LAZY) {
|
|
val candidate = CoroutineScope(readerImageLoadJob + IO).async(start = CoroutineStart.LAZY) {
|
|
|
readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled) ?: ReadBook.book?.let { book ->
|
|
readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled) ?: ReadBook.book?.let { book ->
|
|
|
ImageProvider.getImage(
|
|
ImageProvider.getImage(
|
|
@@ -252,7 +281,7 @@ class ReadBookController(
|
|
|
width = element.bounds.width.toInt().coerceAtLeast(1),
|
|
width = element.bounds.width.toInt().coerceAtLeast(1),
|
|
|
height = element.bounds.height.toInt().coerceAtLeast(1),
|
|
height = element.bounds.height.toInt().coerceAtLeast(1),
|
|
|
).takeUnless(android.graphics.Bitmap::isRecycled)?.also { bitmap ->
|
|
).takeUnless(android.graphics.Bitmap::isRecycled)?.also { bitmap ->
|
|
|
- if (readerImageCacheKey(element) == key) readerImageCache.put(key, bitmap)
|
|
|
|
|
|
|
+ readerImageCache.put(key, bitmap)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -317,6 +346,7 @@ class ReadBookController(
|
|
|
private var composeImageClickAt = 0L
|
|
private var composeImageClickAt = 0L
|
|
|
private var composeImageDoubleClick = false
|
|
private var composeImageDoubleClick = false
|
|
|
private var directReaderLayoutJob: Job? = null
|
|
private var directReaderLayoutJob: Job? = null
|
|
|
|
|
+ private var directReaderAdjacentLayoutJob: Job? = null
|
|
|
private var directReaderLayoutKey: String? = null
|
|
private var directReaderLayoutKey: String? = null
|
|
|
|
|
|
|
|
/** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
|
|
/** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
|
|
@@ -374,6 +404,8 @@ class ReadBookController(
|
|
|
ReadBook.unregisterRender(this)
|
|
ReadBook.unregisterRender(this)
|
|
|
directReaderLayoutJob?.cancel()
|
|
directReaderLayoutJob?.cancel()
|
|
|
directReaderLayoutJob = null
|
|
directReaderLayoutJob = null
|
|
|
|
|
+ directReaderAdjacentLayoutJob?.cancel()
|
|
|
|
|
+ directReaderAdjacentLayoutJob = null
|
|
|
readerImageLoads.values.forEach { it.cancel() }
|
|
readerImageLoads.values.forEach { it.cancel() }
|
|
|
readerImageLoads.clear()
|
|
readerImageLoads.clear()
|
|
|
readerImageCache.evictAll()
|
|
readerImageCache.evictAll()
|
|
@@ -402,6 +434,7 @@ class ReadBookController(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fun onComposeRendererAttached() {
|
|
fun onComposeRendererAttached() {
|
|
|
|
|
+ ReaderPerfTrace.marker("surface.attached")
|
|
|
ReadBook.registerRender(this)
|
|
ReadBook.registerRender(this)
|
|
|
publishReaderPageWindow()
|
|
publishReaderPageWindow()
|
|
|
}
|
|
}
|
|
@@ -445,6 +478,8 @@ class ReadBookController(
|
|
|
|
|
|
|
|
fun onComposeRendererDetached() {
|
|
fun onComposeRendererDetached() {
|
|
|
ReadBook.unregisterRender(this)
|
|
ReadBook.unregisterRender(this)
|
|
|
|
|
+ readerChapterInputPublishJob?.cancel()
|
|
|
|
|
+ readerChapterInputPublishJob = null
|
|
|
cancelReaderImageLoadsExcept(emptySet())
|
|
cancelReaderImageLoadsExcept(emptySet())
|
|
|
readerImageLoadJob.cancel()
|
|
readerImageLoadJob.cancel()
|
|
|
readerImageLoadJob = SupervisorJob()
|
|
readerImageLoadJob = SupervisorJob()
|
|
@@ -644,6 +679,8 @@ class ReadBookController(
|
|
|
private fun rebuildDirectReaderPages() {
|
|
private fun rebuildDirectReaderPages() {
|
|
|
directReaderLayoutJob?.cancel()
|
|
directReaderLayoutJob?.cancel()
|
|
|
directReaderLayoutJob = null
|
|
directReaderLayoutJob = null
|
|
|
|
|
+ directReaderAdjacentLayoutJob?.cancel()
|
|
|
|
|
+ directReaderAdjacentLayoutJob = null
|
|
|
directReaderLayoutKey = null
|
|
directReaderLayoutKey = null
|
|
|
directReaderMayReuseAdjacentPages = false
|
|
directReaderMayReuseAdjacentPages = false
|
|
|
ReadBook.clearReaderPagination()
|
|
ReadBook.clearReaderPagination()
|
|
@@ -794,12 +831,19 @@ class ReadBookController(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
override fun readerChapterInputChanged() {
|
|
override fun readerChapterInputChanged() {
|
|
|
- pendingSearchNavigation?.let { navigation ->
|
|
|
|
|
- ReadBook.readerChapterInputWindow.current
|
|
|
|
|
- ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
|
|
|
|
|
- ?.let { resolveSearchNavigation(navigation, it) }
|
|
|
|
|
|
|
+ // Current/previous/next chapter inputs are published independently during opening.
|
|
|
|
|
+ // Coalesce that short burst so an arriving adjacent chapter does not repeatedly cancel
|
|
|
|
|
+ // the expensive current-chapter measurement before its first page can be committed.
|
|
|
|
|
+ readerChapterInputPublishJob?.cancel()
|
|
|
|
|
+ readerChapterInputPublishJob = activity.lifecycleScope.launch {
|
|
|
|
|
+ delay(80)
|
|
|
|
|
+ pendingSearchNavigation?.let { navigation ->
|
|
|
|
|
+ ReadBook.readerChapterInputWindow.current
|
|
|
|
|
+ ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
|
|
|
|
|
+ ?.let { resolveSearchNavigation(navigation, it) }
|
|
|
|
|
+ }
|
|
|
|
|
+ publishReaderPageWindow()
|
|
|
}
|
|
}
|
|
|
- publishReaderPageWindow()
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private fun resolveSearchNavigation(
|
|
private fun resolveSearchNavigation(
|
|
@@ -880,7 +924,10 @@ class ReadBookController(
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
val key = buildString {
|
|
val key = buildString {
|
|
|
- chapters.forEach { candidate ->
|
|
|
|
|
|
|
+ // The first readable page depends only on the current chapter. Including adjacent
|
|
|
|
|
+ // inputs here made their asynchronous arrival cancel and restart current-chapter
|
|
|
|
|
+ // measurement during opening. They remain in `chapters` for background prelayout.
|
|
|
|
|
+ listOf(chapter).forEach { candidate ->
|
|
|
append(LegacyReaderChapterLayoutIdentity(
|
|
append(LegacyReaderChapterLayoutIdentity(
|
|
|
chapterIndex = candidate.chapter.index,
|
|
chapterIndex = candidate.chapter.index,
|
|
|
chapterUrl = candidate.chapter.url,
|
|
chapterUrl = candidate.chapter.url,
|
|
@@ -937,6 +984,9 @@ class ReadBookController(
|
|
|
ReadBook.durChapterPos,
|
|
ReadBook.durChapterPos,
|
|
|
).also { directReaderPageIndex = it }
|
|
).also { directReaderPageIndex = it }
|
|
|
publishDirectReaderWindow(index)
|
|
publishDirectReaderWindow(index)
|
|
|
|
|
+ scheduleAdjacentReaderPagination(
|
|
|
|
|
+ key, chapter, chapters, width, height, contentPadding, resolvedPaginationStyle
|
|
|
|
|
+ )
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
// A neighboring chapter may already have a complete page set from the preceding
|
|
// A neighboring chapter may already have a complete page set from the preceding
|
|
@@ -957,14 +1007,17 @@ class ReadBookController(
|
|
|
if (directReaderLayoutKey != key) {
|
|
if (directReaderLayoutKey != key) {
|
|
|
val paginationGeneration = ReadBook.readerPaginationGeneration
|
|
val paginationGeneration = ReadBook.readerPaginationGeneration
|
|
|
directReaderLayoutJob?.cancel()
|
|
directReaderLayoutJob?.cancel()
|
|
|
|
|
+ directReaderAdjacentLayoutJob?.cancel()
|
|
|
|
|
+ directReaderAdjacentLayoutJob = null
|
|
|
directReaderLayoutKey = key
|
|
directReaderLayoutKey = key
|
|
|
updateReaderPaginationError(null)
|
|
updateReaderPaginationError(null)
|
|
|
ReadBook.clearReaderPagination()
|
|
ReadBook.clearReaderPagination()
|
|
|
directReaderLayoutJob = activity.lifecycleScope.launch(IO) {
|
|
directReaderLayoutJob = activity.lifecycleScope.launch(IO) {
|
|
|
val highlightRules = HighlightRuleRepository()
|
|
val highlightRules = HighlightRuleRepository()
|
|
|
.loadEnabled(ReadBookConfig.durConfig.name)
|
|
.loadEnabled(ReadBookConfig.durConfig.name)
|
|
|
- suspend fun paginate(candidate: ReaderChapterInput) =
|
|
|
|
|
- candidate.chapter.index to paginateLegacyReaderChapterSafely {
|
|
|
|
|
|
|
+ suspend fun paginate(candidate: ReaderChapterInput, phase: String) =
|
|
|
|
|
+ candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.$phase") {
|
|
|
|
|
+ paginateLegacyReaderChapterSafely {
|
|
|
LegacyReaderChapterPaginator.paginate(
|
|
LegacyReaderChapterPaginator.paginate(
|
|
|
book = candidate.book,
|
|
book = candidate.book,
|
|
|
bookSource = candidate.bookSource,
|
|
bookSource = candidate.bookSource,
|
|
@@ -982,8 +1035,10 @@ class ReadBookController(
|
|
|
paginationStyle = resolvedPaginationStyle,
|
|
paginationStyle = resolvedPaginationStyle,
|
|
|
highlightRules = highlightRules,
|
|
highlightRules = highlightRules,
|
|
|
)
|
|
)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- val currentResult = paginate(chapter)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ val currentResult = paginate(chapter, "current")
|
|
|
val currentBatch = collectLegacyReaderPaginationBatch(
|
|
val currentBatch = collectLegacyReaderPaginationBatch(
|
|
|
currentChapterIndex = chapter.chapter.index,
|
|
currentChapterIndex = chapter.chapter.index,
|
|
|
results = listOf(currentResult),
|
|
results = listOf(currentResult),
|
|
@@ -997,28 +1052,76 @@ class ReadBookController(
|
|
|
paginationGeneration = paginationGeneration,
|
|
paginationGeneration = paginationGeneration,
|
|
|
layoutComplete = chapters.size == 1 || !currentBatch.hasCurrentChapter,
|
|
layoutComplete = chapters.size == 1 || !currentBatch.hasCurrentChapter,
|
|
|
)
|
|
)
|
|
|
|
|
+ if (currentBatch.hasCurrentChapter) {
|
|
|
|
|
+ scheduleAdjacentReaderPagination(
|
|
|
|
|
+ key,
|
|
|
|
|
+ chapter,
|
|
|
|
|
+ chapters,
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ contentPadding,
|
|
|
|
|
+ resolvedPaginationStyle
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- if (!currentBatch.hasCurrentChapter || chapters.size == 1) return@launch
|
|
|
|
|
- val adjacentResults = chapters
|
|
|
|
|
- .filterNot { it.chapter.index == chapter.chapter.index }
|
|
|
|
|
- .map { paginate(it) }
|
|
|
|
|
- val completeBatch = collectLegacyReaderPaginationBatch(
|
|
|
|
|
- currentChapterIndex = chapter.chapter.index,
|
|
|
|
|
- results = adjacentResults + currentResult,
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun scheduleAdjacentReaderPagination(
|
|
|
|
|
+ key: String,
|
|
|
|
|
+ current: ReaderChapterInput,
|
|
|
|
|
+ chapters: List<ReaderChapterInput>,
|
|
|
|
|
+ width: Int,
|
|
|
|
|
+ height: Int,
|
|
|
|
|
+ padding: ReaderPadding,
|
|
|
|
|
+ style: ReaderAndroidPaginationStyle,
|
|
|
|
|
+ ) {
|
|
|
|
|
+ if (directReaderLayoutKey != key) return
|
|
|
|
|
+ val missing = chapters.filter { candidate ->
|
|
|
|
|
+ candidate.chapter.index != current.chapter.index &&
|
|
|
|
|
+ directReaderPages.none { it.id.chapterIndex == candidate.chapter.index && !it.isPlaceholder }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (missing.isEmpty()) return
|
|
|
|
|
+ directReaderAdjacentLayoutJob?.cancel()
|
|
|
|
|
+ val generation = ReadBook.readerPaginationGeneration
|
|
|
|
|
+ directReaderAdjacentLayoutJob = activity.lifecycleScope.launch(IO) {
|
|
|
|
|
+ val rules = HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
|
|
|
|
|
+ val results = missing.map { candidate ->
|
|
|
|
|
+ candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.adjacent") {
|
|
|
|
|
+ paginateLegacyReaderChapterSafely {
|
|
|
|
|
+ LegacyReaderChapterPaginator.paginate(
|
|
|
|
|
+ book = candidate.book,
|
|
|
|
|
+ bookSource = candidate.bookSource,
|
|
|
|
|
+ chapter = candidate.chapter,
|
|
|
|
|
+ displayTitle = candidate.displayTitle,
|
|
|
|
|
+ content = candidate.content,
|
|
|
|
|
+ source = candidate.source,
|
|
|
|
|
+ revision = 31L * key.hashCode() + candidate.chapter.index,
|
|
|
|
|
+ viewportWidthPx = width,
|
|
|
|
|
+ viewportHeightPx = height,
|
|
|
|
|
+ contentPaddingLeftPx = padding.left,
|
|
|
|
|
+ contentPaddingTopPx = padding.top,
|
|
|
|
|
+ contentPaddingRightPx = padding.right,
|
|
|
|
|
+ contentPaddingBottomPx = padding.bottom,
|
|
|
|
|
+ paginationStyle = style,
|
|
|
|
|
+ highlightRules = rules,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ withContext(Main) {
|
|
|
|
|
+ applyDirectReaderPaginationBatch(
|
|
|
|
|
+ key, current, chapters,
|
|
|
|
|
+ collectLegacyReaderPaginationBatch(current.chapter.index, results),
|
|
|
|
|
+ generation, layoutComplete = true,
|
|
|
)
|
|
)
|
|
|
- withContext(Main) {
|
|
|
|
|
- applyDirectReaderPaginationBatch(
|
|
|
|
|
- key = key,
|
|
|
|
|
- currentChapter = chapter,
|
|
|
|
|
- chapters = chapters,
|
|
|
|
|
- batch = completeBatch,
|
|
|
|
|
- paginationGeneration = paginationGeneration,
|
|
|
|
|
- layoutComplete = true,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ if (directReaderAdjacentLayoutJob === coroutineContext[Job]) {
|
|
|
|
|
+ directReaderAdjacentLayoutJob = null
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- return false
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private fun applyDirectReaderPaginationBatch(
|
|
private fun applyDirectReaderPaginationBatch(
|
|
@@ -1029,7 +1132,8 @@ class ReadBookController(
|
|
|
paginationGeneration: Long,
|
|
paginationGeneration: Long,
|
|
|
layoutComplete: Boolean,
|
|
layoutComplete: Boolean,
|
|
|
) {
|
|
) {
|
|
|
- if (directReaderLayoutKey != key) return
|
|
|
|
|
|
|
+ ReaderPerfTrace.section("pagination.commit") {
|
|
|
|
|
+ if (directReaderLayoutKey != key) return@section
|
|
|
if (layoutComplete) directReaderLayoutJob = null
|
|
if (layoutComplete) directReaderLayoutJob = null
|
|
|
batch.unsupportedChapters.forEach { (chapterIndex, reason) ->
|
|
batch.unsupportedChapters.forEach { (chapterIndex, reason) ->
|
|
|
AppLog.putDebug("Compose reader pagination unsupported: chapter=$chapterIndex reason=$reason")
|
|
AppLog.putDebug("Compose reader pagination unsupported: chapter=$chapterIndex reason=$reason")
|
|
@@ -1037,13 +1141,14 @@ class ReadBookController(
|
|
|
updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
|
|
updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
|
|
|
val previousPages = directReaderPages.associateBy { it.id }
|
|
val previousPages = directReaderPages.associateBy { it.id }
|
|
|
val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
|
|
val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
|
|
|
- val retainedPages = if (directReaderMayReuseAdjacentPages) {
|
|
|
|
|
|
|
+ val retainedPages = if (directReaderMayReuseAdjacentPages ||
|
|
|
|
|
+ currentChapter.chapter.index !in replacementChapterIndexes
|
|
|
|
|
+ ) {
|
|
|
directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
|
|
directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
|
|
|
} else {
|
|
} else {
|
|
|
emptyList()
|
|
emptyList()
|
|
|
}
|
|
}
|
|
|
- val replacementPages =
|
|
|
|
|
- batch.pages.takeIf { batch.hasCurrentChapter }.orEmpty().map { page ->
|
|
|
|
|
|
|
+ val replacementPages = batch.pages.map { page ->
|
|
|
previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
|
|
previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
|
|
|
page.copy(layoutRevision = previous.layoutRevision)
|
|
page.copy(layoutRevision = previous.layoutRevision)
|
|
|
} ?: page
|
|
} ?: page
|
|
@@ -1085,6 +1190,7 @@ class ReadBookController(
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
directReaderPageIndex?.let(::publishDirectReaderWindow)
|
|
directReaderPageIndex?.let(::publishDirectReaderWindow)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fun onAppThemeChanged(isDarkTheme: Boolean) {
|
|
fun onAppThemeChanged(isDarkTheme: Boolean) {
|
|
@@ -1094,6 +1200,13 @@ class ReadBookController(
|
|
|
) return
|
|
) return
|
|
|
val startedAt = System.nanoTime()
|
|
val startedAt = System.nanoTime()
|
|
|
val previous = ReadSessionState.isDarkThemeOverride
|
|
val previous = ReadSessionState.isDarkThemeOverride
|
|
|
|
|
+ // ToggleDayNight installs the target override before DataStore publishes the app
|
|
|
|
|
+ // configuration, so a relayout started in that interval already uses the target
|
|
|
|
|
+ // colors. Restore the last applied mode briefly to obtain the source colors for
|
|
|
|
|
+ // any already-rendered pages, then apply the target mode atomically.
|
|
|
|
|
+ val sourceMode = appliedDarkTheme ?: previous
|
|
|
|
|
+ val modeChanged = appliedDarkTheme != null && appliedDarkTheme != isDarkTheme
|
|
|
|
|
+ if (sourceMode != null) ReadSessionState.isDarkThemeOverride = sourceMode
|
|
|
val oldTextColor = ReadBookConfig.textColor
|
|
val oldTextColor = ReadBookConfig.textColor
|
|
|
val oldTitleColor = ReadBookConfig.resolvedTitleColor.takeIf { it != 0 } ?: oldTextColor
|
|
val oldTitleColor = ReadBookConfig.resolvedTitleColor.takeIf { it != 0 } ?: oldTextColor
|
|
|
val oldShadowColor = ReadBookConfig.textShadowColor
|
|
val oldShadowColor = ReadBookConfig.textShadowColor
|
|
@@ -1126,6 +1239,13 @@ class ReadBookController(
|
|
|
layoutController.viewport.value?.let { viewport ->
|
|
layoutController.viewport.value?.let { viewport ->
|
|
|
updateComposeReaderBackground(viewport.widthPx, viewport.heightPx)
|
|
updateComposeReaderBackground(viewport.widthPx, viewport.heightPx)
|
|
|
}
|
|
}
|
|
|
|
|
+ // 旧 View 的日夜切换是重建整个阅读 Activity:`onDestroy` → `ReadBook.unregister()` →
|
|
|
|
|
+ // `ImageProvider.clear()`,重建后 `loadOrUpContent()` 重新加载正文。图片之所以会变,
|
|
|
|
|
+ // 是因为正文会被重新处理(替换规则 / 图片解码可以用 java.getThemeMode()、
|
|
|
|
|
+ // java.getThemeConfig() 产出与主题相关的图片),而不是因为清了解码缓存。
|
|
|
|
|
+ // Compose 不重建 Activity,这里走与样式方案切换相同的路径:
|
|
|
|
|
+ // 重下当前窗口图片 → 替换位图 → loadContent(false) 重新处理正文并重排。
|
|
|
|
|
+ if (modeChanged) refreshInlineImagesThenReload()
|
|
|
upSystemUiVisibility()
|
|
upSystemUiVisibility()
|
|
|
LogUtils.d(
|
|
LogUtils.d(
|
|
|
"ReadBookTheme",
|
|
"ReadBookTheme",
|
|
@@ -1653,7 +1773,11 @@ class ReadBookController(
|
|
|
ConfigUpdateAction.UpdatePageSlopSquare,
|
|
ConfigUpdateAction.UpdatePageSlopSquare,
|
|
|
ConfigUpdateAction.RefreshInlineImages -> Unit
|
|
ConfigUpdateAction.RefreshInlineImages -> Unit
|
|
|
|
|
|
|
|
- ConfigUpdateAction.ReloadContent -> if (!refreshInlineImages && viewModel.isInitFinish) {
|
|
|
|
|
|
|
+ // 旧事件 5。带 RefreshInlineImages 的路径(样式方案/预设切换)由
|
|
|
|
|
+ // refreshInlineImagesThenReload() 在图片替换完成后统一重载,避免两次重排。
|
|
|
|
|
+ ConfigUpdateAction.ReloadContent -> if (
|
|
|
|
|
+ !refreshInlineImages && viewModel.isInitFinish
|
|
|
|
|
+ ) {
|
|
|
ReadBook.loadContent(resetPageOffset = false)
|
|
ReadBook.loadContent(resetPageOffset = false)
|
|
|
}
|
|
}
|
|
|
ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
|
|
ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
|
|
@@ -1707,7 +1831,13 @@ class ReadBookController(
|
|
|
_composeSelectionCancels.tryEmit(Unit)
|
|
_composeSelectionCancels.tryEmit(Unit)
|
|
|
}
|
|
}
|
|
|
is ReadBookEffect.MenuImageStyleChanged -> rebuildDirectReaderPages()
|
|
is ReadBookEffect.MenuImageStyleChanged -> rebuildDirectReaderPages()
|
|
|
- is ReadBookEffect.InvalidateReaderImage -> invalidateReaderImage(effect.source)
|
|
|
|
|
|
|
+ is ReadBookEffect.InvalidateReaderImage -> {
|
|
|
|
|
+ activity.lifecycleScope.launch { replaceReaderImages(setOf(effect.source)) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ is ReadBookEffect.InvalidateReaderImages -> {
|
|
|
|
|
+ activity.lifecycleScope.launch { replaceReaderImages(effect.sources) }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// ── Simple Activity-API effects ──
|
|
// ── Simple Activity-API effects ──
|
|
|
is ReadBookEffect.ShowToast -> activity.toastOnUi(effect.message)
|
|
is ReadBookEffect.ShowToast -> activity.toastOnUi(effect.message)
|