Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -59,6 +59,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
|
|
@ -76,6 +78,7 @@ private const val PRIORITY_HIGHEST = 0
|
|||
private const val PRIORITY_HIGH = 1
|
||||
private const val PRIORITY_MEDIUM = 2
|
||||
private const val PRIORITY_LOW = 3
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
|
||||
data class TimedWord(val word: String, val startTime: Double, val startOffset: Int)
|
||||
|
||||
|
|
@ -187,6 +190,7 @@ class BookPaginator(
|
|||
|
||||
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
||||
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
||||
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
|
||||
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
|
||||
private var paginationWorker: Job? = null
|
||||
|
||||
|
|
@ -430,9 +434,9 @@ class BookPaginator(
|
|||
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
|
||||
null
|
||||
} else {
|
||||
pageCache.put(chapterIndex, pages)
|
||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
|
||||
pages
|
||||
}
|
||||
|
|
@ -574,8 +578,86 @@ class BookPaginator(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
|
||||
if (chapterIndex !in chapters.indices) {
|
||||
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
|
||||
return null
|
||||
}
|
||||
|
||||
pageCache[chapterIndex]?.let {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||
.d("ensure_chapter hit_memory chapter=$chapterIndex pages=${it.size}")
|
||||
return it
|
||||
}
|
||||
|
||||
val lock = chapterPaginationLocks.computeIfAbsent(chapterIndex) { Mutex() }
|
||||
return lock.withLock {
|
||||
pageCache[chapterIndex]?.also {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||
.d("ensure_chapter hit_after_wait chapter=$chapterIndex pages=${it.size}")
|
||||
} ?: run {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||
.d("ensure_chapter paginate chapter=$chapterIndex finalized=${chapterIndex in finalizedChapterCounts}")
|
||||
paginateChapter(chapterIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
|
||||
)
|
||||
return resolveStableChapterStartPage(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterCount = chapters.size,
|
||||
pageCountsAreAccurate = pageCountsAreAccurate,
|
||||
chapterStartPage = { chapterStartPageIndices[it] },
|
||||
isChapterFinalized = { it in finalizedChapterCounts },
|
||||
ensureChapterPaginated = { ensureChapterPaginated(it) != null }
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun findStableChapterStartPage(chapterIndex: Int): Int? = withContext(Dispatchers.IO) {
|
||||
val stableStart = ensureStableStartPageForChapter(chapterIndex) ?: return@withContext null
|
||||
val targetPages = ensureChapterPaginated(chapterIndex) ?: return@withContext null
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"stable_chapter_start resolved chapter=$chapterIndex page=$stableStart targetPages=${targetPages.size}"
|
||||
)
|
||||
stableStart.takeIf { targetPages.isNotEmpty() }
|
||||
}
|
||||
|
||||
suspend fun findStablePageForLocator(locator: Locator): Int? = withContext(Dispatchers.IO) {
|
||||
val targetChapterIndex = locator.chapterIndex
|
||||
Timber.tag("POS_DIAG").d("findStablePageForLocator: Searching for $locator")
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d("stable_locator request locator=$locator")
|
||||
|
||||
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||
|
||||
Timber.tag("POS_DIAG").d(
|
||||
"findStablePageForLocator: targetChapterIndex=$targetChapterIndex, stableStart=$chapterStartPage, chapterPages.size=${chapterPages?.size}"
|
||||
)
|
||||
|
||||
if (chapterPages.isNullOrEmpty() || chapterStartPage == null) {
|
||||
Timber.e("Stable locator navigation failed: Could not stabilize target chapter $targetChapterIndex.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
|
||||
Timber.tag("POS_DIAG").e("findStablePageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val finalPageIndex = chapterStartPage + pageInChapter
|
||||
Timber.tag("POS_DIAG").i("findStablePageForLocator: FOUND absolute page $finalPageIndex")
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"stable_locator resolved locator=$locator page=$finalPageIndex chapterStart=$chapterStartPage pageInChapter=$pageInChapter"
|
||||
)
|
||||
finalPageIndex
|
||||
}
|
||||
|
||||
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
|
||||
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
val pages = ensureChapterPaginated(chapterIndex)
|
||||
if (pages.isNullOrEmpty()) {
|
||||
Timber.w("PAGINATOR: Chapter $chapterIndex has no pages or could not be paginated.")
|
||||
return null
|
||||
|
|
@ -794,7 +876,7 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
Timber.i("Worker: Starting pagination for chapter $chapterIndex.")
|
||||
val pages = paginateChapter(chapterIndex)
|
||||
val pages = ensureChapterPaginated(chapterIndex)
|
||||
|
||||
if (pages != null) {
|
||||
Timber.i("Worker: Successfully finished pagination for chapter $chapterIndex.")
|
||||
|
|
@ -836,6 +918,9 @@ class BookPaginator(
|
|||
val difference = actualPageCount - estimatedPageCount
|
||||
|
||||
if (difference == 0) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
|
||||
)
|
||||
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
|
||||
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
|
||||
}
|
||||
|
|
@ -854,8 +939,16 @@ class BookPaginator(
|
|||
}
|
||||
rebuildChapterStartSnapshot()
|
||||
|
||||
if (chapterIndex < currentUserChapterIndex.value) {
|
||||
pageShiftRequest.tryEmit(difference)
|
||||
val currentUserChapter = currentUserChapterIndex.value
|
||||
val shouldShiftCurrentPage = chapterIndex < currentUserChapter
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_count_update chapter=$chapterIndex estimated=$estimatedPageCount actual=$actualPageCount diff=$difference currentUserChapter=$currentUserChapter shiftCurrent=$shouldShiftCurrentPage total=$totalPageCount"
|
||||
)
|
||||
if (shouldShiftCurrentPage) {
|
||||
val emitted = pageShiftRequest.tryEmit(difference)
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"page_shift_emit chapter=$chapterIndex diff=$difference currentUserChapter=$currentUserChapter emitted=$emitted"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1028,13 +1121,12 @@ class BookPaginator(
|
|||
)
|
||||
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
|
||||
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||
|
||||
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||
savePageCacheAsync(chapter, chapterIndex, pages)
|
||||
|
||||
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||
pageCache.put(chapterIndex, pages)
|
||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||
return pages
|
||||
}
|
||||
|
||||
|
|
@ -1105,12 +1197,68 @@ class BookPaginator(
|
|||
return Jsoup.parse(htmlToParse).body().text()
|
||||
}
|
||||
|
||||
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
|
||||
if (targetChapterIndex <= 0) {
|
||||
return 0
|
||||
suspend fun findStablePageForAnchor(chapterIndex: Int, anchor: String?): Int? = withContext(Dispatchers.IO) {
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Stable precision nav request for anchor: '$anchor'")
|
||||
|
||||
if (anchor.isNullOrBlank()) {
|
||||
return@withContext findStableChapterStartPage(chapterIndex)
|
||||
}
|
||||
val startIndex = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
return startIndex
|
||||
|
||||
// 1. QUICK LOOKUP: Check the Anchor Index first
|
||||
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||
|
||||
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
||||
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
||||
indexEntry.chapterIndex to indexEntry.blockIndex
|
||||
} else {
|
||||
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
||||
chapterIndex to null
|
||||
}
|
||||
|
||||
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
||||
val chapterPages = ensureChapterPaginated(targetChapter)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapter)
|
||||
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("Anchor navigation failed: Could not stabilize target chapter $targetChapter.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
||||
chapterPageNavigationIndex[targetChapter]
|
||||
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.pageInChapter
|
||||
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
||||
|
||||
if (indexedPageInChapter != null) {
|
||||
val finalPage = chapterStartPage + indexedPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
||||
return@withContext finalPage
|
||||
}
|
||||
|
||||
// 3. FIND PAGE
|
||||
var targetPageInChapter = 0
|
||||
var found = false
|
||||
|
||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val isMatch = if (targetBlock != null) {
|
||||
// Fast path: We know exactly which block we are looking for
|
||||
page.content.any { it.blockIndex == targetBlock }
|
||||
} else {
|
||||
// Slow path: Linear ID scan (fallback)
|
||||
page.content.any { containsAnchor(it, anchor) }
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
targetPageInChapter = pageIndex
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val finalPage = chapterStartPage + targetPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
||||
finalPage
|
||||
}
|
||||
|
||||
override fun findPageForAnchor(
|
||||
|
|
@ -1119,70 +1267,8 @@ class BookPaginator(
|
|||
onResult: (pageIndex: Int) -> Unit
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Precision nav request for anchor: '$anchor'")
|
||||
|
||||
if (anchor.isNullOrBlank()) {
|
||||
val start = chapterStartPageIndices[chapterIndex] ?: 0
|
||||
withContext(Dispatchers.Main) { onResult(start) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 1. QUICK LOOKUP: Check the Anchor Index first
|
||||
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
|
||||
|
||||
val (targetChapter, targetBlock) = if (indexEntry != null) {
|
||||
Timber.tag("TOC_NAV_DEBUG").i("Index HIT: Anchor '$anchor' is in Chapter ${indexEntry.chapterIndex}, Block ${indexEntry.blockIndex}")
|
||||
indexEntry.chapterIndex to indexEntry.blockIndex
|
||||
} else {
|
||||
Timber.tag("TOC_NAV_DEBUG").w("Index MISS: Falling back to linear scan for '$anchor' in Chapter $chapterIndex")
|
||||
chapterIndex to null
|
||||
}
|
||||
|
||||
// 2. ENSURE PAGINATION: Get pages for the determined chapter
|
||||
val chapterPages = pageCache[targetChapter] ?: paginateChapter(targetChapter)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapter] ?: 0
|
||||
|
||||
if (chapterPages == null) {
|
||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val indexedPageInChapter = targetBlock?.let { blockIndex ->
|
||||
chapterPageNavigationIndex[targetChapter]
|
||||
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.pageInChapter
|
||||
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
|
||||
|
||||
if (indexedPageInChapter != null) {
|
||||
val finalPage = chapterStartPage + indexedPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
|
||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 3. FIND PAGE
|
||||
var targetPageInChapter = 0
|
||||
var found = false
|
||||
|
||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val isMatch = if (targetBlock != null) {
|
||||
// Fast path: We know exactly which block we are looking for
|
||||
page.content.any { it.blockIndex == targetBlock }
|
||||
} else {
|
||||
// Slow path: Linear ID scan (fallback)
|
||||
page.content.any { containsAnchor(it, anchor) }
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
targetPageInChapter = pageIndex
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val finalPage = chapterStartPage + targetPageInChapter
|
||||
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved to Absolute Page: $finalPage (Found: $found)")
|
||||
withContext(Dispatchers.Main) { onResult(finalPage) }
|
||||
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onResult(page) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1234,69 +1320,79 @@ class BookPaginator(
|
|||
onNavigationComplete: (pageIndex: Int) -> Unit
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
||||
|
||||
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||
if (targetChapterPath == null) {
|
||||
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||
if (targetChapterIndex == -1) {
|
||||
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||
return@launch
|
||||
}
|
||||
|
||||
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
|
||||
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findStablePageForHref(currentChapterAbsPath: String, href: String): Int? = withContext(Dispatchers.IO) {
|
||||
Timber.i("Navigating to href: '$href' from chapter: '$currentChapterAbsPath'")
|
||||
|
||||
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
|
||||
if (targetChapterPath == null) {
|
||||
Timber.w("Could not resolve href '$href' to a valid chapter path.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
|
||||
if (targetChapterIndex == -1) {
|
||||
Timber.w("Could not find chapter for path: $targetChapterPath")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
findStablePageForAnchor(targetChapterIndex, anchor)
|
||||
}
|
||||
|
||||
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
|
||||
val targetChapterIndex = result.locationInSource
|
||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||
|
||||
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("Search result navigation failed: Could not stabilize target chapter $targetChapterIndex.")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
var targetPageInChapter = 0
|
||||
var occurrenceCount = 0
|
||||
|
||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
val textToSearch = when (block) {
|
||||
is ParagraphBlock -> block.content.text
|
||||
is HeaderBlock -> block.content.text
|
||||
is QuoteBlock -> block.content.text
|
||||
is ListItemBlock -> block.content.text
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (textToSearch != null) {
|
||||
var lastIndex = -1
|
||||
while (true) {
|
||||
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||
if (lastIndex == -1) break
|
||||
|
||||
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||
targetPageInChapter = pageIndex
|
||||
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
||||
break@pageLoop
|
||||
}
|
||||
occurrenceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||
Timber.i("Search result found. Final page index: $finalPageIndex")
|
||||
finalPageIndex
|
||||
}
|
||||
|
||||
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val targetChapterIndex = result.locationInSource
|
||||
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
|
||||
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
var targetPageInChapter = 0
|
||||
var occurrenceCount = 0
|
||||
|
||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
val textToSearch = when (block) {
|
||||
is ParagraphBlock -> block.content.text
|
||||
is HeaderBlock -> block.content.text
|
||||
is QuoteBlock -> block.content.text
|
||||
is ListItemBlock -> block.content.text
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (textToSearch != null) {
|
||||
var lastIndex = -1
|
||||
while (true) {
|
||||
lastIndex = textToSearch.indexOf(result.query, startIndex = lastIndex + 1, ignoreCase = true)
|
||||
if (lastIndex == -1) break
|
||||
|
||||
if (occurrenceCount == result.occurrenceIndexInLocation) {
|
||||
targetPageInChapter = pageIndex
|
||||
Timber.i("Found search result '${result.query}' at occurrence ${result.occurrenceIndexInLocation} on page $pageIndex of chapter $targetChapterIndex")
|
||||
break@pageLoop
|
||||
}
|
||||
occurrenceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||
Timber.i("Search result found. Final page index: $finalPageIndex")
|
||||
withContext(Dispatchers.Main) { onResult(finalPageIndex) }
|
||||
val page = findStablePageForSearchResult(result) ?: return@launch
|
||||
withContext(Dispatchers.Main) { onResult(page) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1331,20 +1427,8 @@ class BookPaginator(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun findPageForLocator(locator: Locator): Int? {
|
||||
private fun findPageInChapterForLocator(locator: Locator, chapterPages: List<Page>): Int? {
|
||||
val targetChapterIndex = locator.chapterIndex
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
|
||||
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
|
||||
|
||||
if (chapterPages.isNullOrEmpty()) {
|
||||
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return null
|
||||
}
|
||||
|
||||
chapterTextRangeIndex[targetChapterIndex]
|
||||
?.firstOrNull { range ->
|
||||
range.blockIndex == locator.blockIndex &&
|
||||
|
|
@ -1352,17 +1436,15 @@ class BookPaginator(
|
|||
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
|
||||
}
|
||||
?.let { range ->
|
||||
val finalPageIndex = chapterStartPage + range.pageInChapter
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND via runtime index on pageInChapter ${range.pageInChapter}")
|
||||
return range.pageInChapter
|
||||
}
|
||||
|
||||
chapterPageNavigationIndex[targetChapterIndex]
|
||||
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
|
||||
?.let { entry ->
|
||||
val finalPageIndex = chapterStartPage + entry.pageInChapter
|
||||
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Using block-range fallback pageInChapter ${entry.pageInChapter}")
|
||||
return entry.pageInChapter
|
||||
}
|
||||
|
||||
var fallbackPageInChapter = -1
|
||||
|
|
@ -1370,7 +1452,7 @@ class BookPaginator(
|
|||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
val allTextBlocks = getAllTextBlocks(page.content)
|
||||
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
|
||||
Timber.tag("POS_DIAG").d("findPageInChapterForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex")
|
||||
}
|
||||
for (textBlock in allTextBlocks) {
|
||||
if (textBlock.blockIndex == locator.blockIndex) {
|
||||
|
|
@ -1384,15 +1466,13 @@ class BookPaginator(
|
|||
|
||||
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
|
||||
if (isInside) {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND match on pageInChapter $pageIndex")
|
||||
return pageIndex
|
||||
}
|
||||
|
||||
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
|
||||
val finalPageIndex = chapterStartPage + pageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").i("findPageInChapterForLocator: FOUND empty block match on pageInChapter $pageIndex")
|
||||
return pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1408,15 +1488,37 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
if (fallbackPageInChapter != -1) {
|
||||
val finalPageIndex = chapterStartPage + fallbackPageInChapter
|
||||
Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
Timber.tag("POS_DIAG").w("findPageInChapterForLocator: Exact offset not found, using block-start fallback pageInChapter $fallbackPageInChapter")
|
||||
return fallbackPageInChapter
|
||||
}
|
||||
|
||||
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun findPageForLocator(locator: Locator): Int? {
|
||||
val targetChapterIndex = locator.chapterIndex
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
|
||||
|
||||
val chapterPages = ensureChapterPaginated(targetChapterIndex)
|
||||
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
|
||||
|
||||
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
|
||||
|
||||
if (chapterPages.isNullOrEmpty()) {
|
||||
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return null
|
||||
}
|
||||
|
||||
val pageInChapter = findPageInChapterForLocator(locator, chapterPages) ?: run {
|
||||
Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
|
||||
return null
|
||||
}
|
||||
|
||||
val finalPageIndex = chapterStartPage + pageInChapter
|
||||
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND absolute page $finalPageIndex")
|
||||
return finalPageIndex
|
||||
}
|
||||
|
||||
fun getLocatorForPage(pageIndex: Int): Locator? {
|
||||
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
|
||||
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
||||
|
|
@ -1472,13 +1574,12 @@ class BookPaginator(
|
|||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
|
||||
|
||||
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
|
||||
val chapterPages = ensureChapterPaginated(chapterIndex)
|
||||
val chapterStartPage = ensureStableStartPageForChapter(chapterIndex)
|
||||
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")
|
||||
withContext(Dispatchers.Main) { onResult(chapterStartPage) }
|
||||
if (chapterPages == null || chapterStartPage == null) {
|
||||
Timber.e("CFI Navigation failed: Could not stabilize target chapter $chapterIndex.")
|
||||
return@launch
|
||||
}
|
||||
Timber.d("findPageForCfi: Chapter $chapterIndex has ${chapterPages.size} pages.")
|
||||
|
|
|
|||
|
|
@ -433,6 +433,14 @@ class ContentStyler(
|
|||
baselineShift = baselineShift
|
||||
)
|
||||
|
||||
if (!span.linkHref.isNullOrBlank()) {
|
||||
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor
|
||||
)
|
||||
}
|
||||
|
||||
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
|
||||
themedSpanStyle.textDecorationColor.isSpecified ||
|
||||
themedSpanStyle.textUnderlineOffset.isSpecified
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import android.webkit.JavascriptInterface
|
|||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import com.aryan.reader.BuildConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
|
@ -44,6 +43,8 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
private var webView: WebView? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@Volatile
|
||||
private var isDestroyed = false
|
||||
private var isMathJaxReady = false
|
||||
private val readySignal = CompletableDeferred<Boolean>()
|
||||
|
||||
|
|
@ -59,11 +60,14 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
init {
|
||||
handler.post {
|
||||
setupWebView()
|
||||
if (!isDestroyed) {
|
||||
setupWebView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitReady(): Boolean {
|
||||
if (isDestroyed) return false
|
||||
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
|
||||
return withTimeoutOrNull(10_000) {
|
||||
readySignal.await()
|
||||
|
|
@ -76,9 +80,7 @@ class MathMLRenderer(private val context: Context) {
|
|||
|
||||
private fun setupWebView() {
|
||||
try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
if (isDestroyed) return
|
||||
|
||||
webView = WebView(context).apply {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
|
|
@ -114,6 +116,9 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
suspend fun render(mathML: String, originalAltText: String): RenderResult {
|
||||
if (isDestroyed) {
|
||||
return RenderResult.Failure(originalAltText)
|
||||
}
|
||||
if (!awaitReady()) {
|
||||
Timber.e("WebView is not available or failed to initialize. Failing render.")
|
||||
return RenderResult.Failure(originalAltText)
|
||||
|
|
@ -140,6 +145,10 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
private fun processNextJob() {
|
||||
if (isDestroyed) {
|
||||
isProcessing = false
|
||||
return
|
||||
}
|
||||
synchronized(jobQueue) {
|
||||
if (jobQueue.isEmpty()) {
|
||||
isProcessing = false
|
||||
|
|
@ -153,6 +162,10 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
private fun executeRender() {
|
||||
if (isDestroyed) {
|
||||
isProcessing = false
|
||||
return
|
||||
}
|
||||
if (!isMathJaxReady) {
|
||||
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
|
||||
handler.postDelayed({ executeRender() }, 100)
|
||||
|
|
@ -208,14 +221,43 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
|
||||
fun destroy() {
|
||||
isDestroyed = true
|
||||
if (!readySignal.isCompleted) {
|
||||
readySignal.complete(false)
|
||||
}
|
||||
val pendingJobs = synchronized(jobQueue) {
|
||||
val copy = jobQueue.toList()
|
||||
jobQueue.clear()
|
||||
isProcessing = false
|
||||
copy
|
||||
}
|
||||
pendingJobs.forEach { job ->
|
||||
job.continuation(RenderResult.Failure(extractAltText(job.mathML)))
|
||||
}
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
handler.post {
|
||||
webView?.destroy()
|
||||
webView?.releaseMathRendererResources()
|
||||
webView = null
|
||||
Timber.d("MathMLRenderer WebView destroyed.")
|
||||
}
|
||||
synchronized(jobQueue) {
|
||||
jobQueue.clear()
|
||||
isProcessing = false
|
||||
}
|
||||
|
||||
private fun extractAltText(mathML: String): String =
|
||||
mathML.substringAfter("alttext=\"", "").substringBefore("\"")
|
||||
.ifBlank { "MathML rendering failed" }
|
||||
|
||||
private fun WebView.releaseMathRendererResources() {
|
||||
try {
|
||||
stopLoading()
|
||||
removeJavascriptInterface("AndroidBridge")
|
||||
webChromeClient = null
|
||||
webViewClient = WebViewClient()
|
||||
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
|
||||
clearHistory()
|
||||
removeAllViews()
|
||||
destroy()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to fully release MathML WebView resources")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +271,7 @@ class MathMLRenderer(private val context: Context) {
|
|||
} else {
|
||||
Timber.e("onSvgReady FAILURE. Received empty SVG.")
|
||||
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
|
||||
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
|
||||
val altText = job?.mathML?.let(::extractAltText) ?: "MathML rendering failed"
|
||||
completeCurrentJob(RenderResult.Failure(altText))
|
||||
}
|
||||
}
|
||||
|
|
@ -244,4 +286,4 @@ class MathMLRenderer(private val context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.runtime.SideEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -253,6 +254,8 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
|||
}
|
||||
|
||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
||||
|
||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||
|
|
@ -410,6 +413,30 @@ internal object CfiUtils {
|
|||
|
||||
fun getPath(cfi: String): String = cfi.split(':').first()
|
||||
fun getOffset(cfi: String): Int = cfi.substringAfter(':', "0").toIntOrNull() ?: 0
|
||||
fun getOffsetOrNull(cfi: String): Int? = cfi.substringAfter(':', "").toIntOrNull()
|
||||
|
||||
fun isPathStrictlyBetween(candidate: String, start: String, end: String): Boolean {
|
||||
val candidateParts = pathParts(candidate) ?: return false
|
||||
val startParts = pathParts(start) ?: return false
|
||||
val endParts = pathParts(end) ?: return false
|
||||
return comparePathParts(candidateParts, startParts) > 0 &&
|
||||
comparePathParts(candidateParts, endParts) < 0
|
||||
}
|
||||
|
||||
private fun pathParts(cfi: String): List<Int>? {
|
||||
val segments = getPath(cfi).split('/').filter { it.isNotEmpty() }
|
||||
if (segments.isEmpty()) return null
|
||||
return segments.map { it.toIntOrNull() ?: return null }
|
||||
}
|
||||
|
||||
private fun comparePathParts(first: List<Int>, second: List<Int>): Int {
|
||||
val length = minOf(first.size, second.size)
|
||||
for (index in 0 until length) {
|
||||
val cmp = first[index].compareTo(second[index])
|
||||
if (cmp != 0) return cmp
|
||||
}
|
||||
return first.size.compareTo(second.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun highlightQueryInText(
|
||||
|
|
@ -729,6 +756,7 @@ fun PaginatedReaderScreen(
|
|||
effectiveText: Color,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
isRightToLeftPagination: Boolean = false,
|
||||
searchQuery: String,
|
||||
fontSizeMultiplier: Float,
|
||||
lineHeightMultiplier: Float,
|
||||
|
|
@ -741,6 +769,9 @@ fun PaginatedReaderScreen(
|
|||
ttsHighlightInfo: TtsHighlightInfo?,
|
||||
initialChapterIndexInBook: Int?,
|
||||
fallbackLocatorForReconfiguration: Locator? = null,
|
||||
explicitNavigationAnchor: Locator? = null,
|
||||
explicitNavigationEpoch: Long = 0L,
|
||||
isExternalNavigationInProgress: Boolean = false,
|
||||
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
|
||||
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
|
||||
onPaginatorReady: (IPaginator) -> Unit,
|
||||
|
|
@ -754,6 +785,7 @@ fun PaginatedReaderScreen(
|
|||
onStartTtsFromSelection: (String, Int) -> Unit,
|
||||
onNoteRequested: (String?) -> Unit,
|
||||
onFootnoteRequested: (String) -> Unit,
|
||||
onInternalLinkNavigated: (Int) -> Unit = {},
|
||||
userHighlights: List<UserHighlight>,
|
||||
onHighlightCreated: (String, String, String) -> Unit,
|
||||
onHighlightDeleted: (String) -> Unit,
|
||||
|
|
@ -784,6 +816,11 @@ fun PaginatedReaderScreen(
|
|||
} else Modifier
|
||||
|
||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||
var localExplicitNavigationAnchor by remember { mutableStateOf<Locator?>(null) }
|
||||
var localExplicitNavigationEpoch by remember { mutableLongStateOf(0L) }
|
||||
val latestExternalNavigationAnchor by rememberUpdatedState(explicitNavigationAnchor)
|
||||
val latestExternalNavigationEpoch by rememberUpdatedState(explicitNavigationEpoch)
|
||||
val latestIsExternalNavigationInProgress by rememberUpdatedState(isExternalNavigationInProgress)
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
|
@ -1107,19 +1144,96 @@ fun PaginatedReaderScreen(
|
|||
|
||||
LaunchedEffect(paginator, pagerState) {
|
||||
paginator.pageShiftRequest.collect { shiftAmount ->
|
||||
val anchor = resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator = anchorLocatorForReconfig,
|
||||
fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||
if (pagerState.pageCount <= 0) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV)
|
||||
.w("shift_drop reason=emptyPager shift=$shiftAmount")
|
||||
return@collect
|
||||
}
|
||||
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val currentPageBeforeShift = pagerState.currentPage
|
||||
val now = System.currentTimeMillis()
|
||||
val externalAgeMs = if (latestExternalNavigationEpoch > 0L) {
|
||||
now - latestExternalNavigationEpoch
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
val localAgeMs = if (localExplicitNavigationEpoch > 0L) {
|
||||
now - localExplicitNavigationEpoch
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
val recentExternalNavigation =
|
||||
externalAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||
val recentLocalNavigation =
|
||||
localAgeMs in 0L..EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS
|
||||
val activeExplicitAnchor = when {
|
||||
latestIsExternalNavigationInProgress -> latestExternalNavigationAnchor
|
||||
isNavigatingByLink -> localExplicitNavigationAnchor
|
||||
else -> null
|
||||
}
|
||||
val recentExplicitAnchor = when {
|
||||
recentExternalNavigation -> latestExternalNavigationAnchor
|
||||
recentLocalNavigation -> localExplicitNavigationAnchor
|
||||
else -> null
|
||||
}
|
||||
val activeExplicitAnchorSource = when {
|
||||
activeExplicitAnchor == null -> null
|
||||
latestIsExternalNavigationInProgress -> "explicit_external_active"
|
||||
else -> "explicit_link"
|
||||
}
|
||||
val recentExplicitAnchorSource = when {
|
||||
recentExplicitAnchor == null -> null
|
||||
recentExternalNavigation -> "explicit_external_recent"
|
||||
else -> "explicit_link_recent"
|
||||
}
|
||||
val currentPageLocator = bookPaginator?.getLocatorForPage(currentPageBeforeShift)
|
||||
val fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||
var anchorSource = "none"
|
||||
val anchor = when {
|
||||
anchorLocatorForReconfig != null -> {
|
||||
anchorSource = "reconfiguration"
|
||||
anchorLocatorForReconfig
|
||||
}
|
||||
activeExplicitAnchor != null -> {
|
||||
anchorSource = activeExplicitAnchorSource ?: "explicit_active"
|
||||
activeExplicitAnchor
|
||||
}
|
||||
fallbackLocator != null -> {
|
||||
anchorSource = "last_known"
|
||||
fallbackLocator
|
||||
}
|
||||
recentExplicitAnchor != null -> {
|
||||
anchorSource = recentExplicitAnchorSource ?: "explicit_recent"
|
||||
recentExplicitAnchor
|
||||
}
|
||||
currentPageLocator != null -> {
|
||||
anchorSource = "current_page"
|
||||
currentPageLocator
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"shift_received shift=$shiftAmount currentPage=$currentPageBeforeShift anchorSource=$anchorSource anchor=$anchor currentLocator=$currentPageLocator fallback=$fallbackLocator externalInProgress=$latestIsExternalNavigationInProgress linkInProgress=$isNavigatingByLink externalAgeMs=$externalAgeMs localAgeMs=$localAgeMs"
|
||||
)
|
||||
|
||||
val resolvedPage = anchor?.let { locator ->
|
||||
(paginator as? BookPaginator)?.findPageForLocator(locator)
|
||||
bookPaginator?.findStablePageForLocator(locator)
|
||||
}
|
||||
|
||||
if (resolvedPage != null) {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"shift_apply_stable shift=$shiftAmount from=$currentPageBeforeShift to=$resolvedPage anchorSource=$anchorSource anchor=$anchor"
|
||||
)
|
||||
pagerState.scrollToPage(resolvedPage)
|
||||
paginator.onUserScrolledTo(resolvedPage)
|
||||
} else {
|
||||
val newPage = pagerState.currentPage + shiftAmount
|
||||
val maxPage = (pagerState.pageCount - 1).coerceAtLeast(0)
|
||||
val newPage = (currentPageBeforeShift + shiftAmount).coerceIn(0, maxPage)
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||
"shift_apply_relative shift=$shiftAmount from=$currentPageBeforeShift to=$newPage anchorSource=$anchorSource anchor=$anchor"
|
||||
)
|
||||
pagerState.scrollToPage(newPage)
|
||||
paginator.onUserScrolledTo(newPage)
|
||||
}
|
||||
|
|
@ -1134,6 +1248,7 @@ fun PaginatedReaderScreen(
|
|||
uiState = uiState,
|
||||
pagerState = pagerState,
|
||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||
isRightToLeftPagination = isRightToLeftPagination,
|
||||
effectiveBg = effectiveBg,
|
||||
searchQuery = searchQuery,
|
||||
ttsHighlightInfo = ttsHighlightInfo,
|
||||
|
|
@ -1163,100 +1278,126 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
}
|
||||
},
|
||||
onInternalLinkNavigated = onInternalLinkNavigated,
|
||||
onLinkClick = { currentChapterPath, href, onNavComplete ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
isNavigatingByLink = true
|
||||
var isFootnote = false
|
||||
var footnoteHtml: String? = null
|
||||
withContext(Dispatchers.Main) { isNavigatingByLink = true }
|
||||
try {
|
||||
var isFootnote = false
|
||||
var footnoteHtml: String? = null
|
||||
|
||||
val sourceChapter =
|
||||
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
||||
if (sourceChapter != null) {
|
||||
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
||||
.readText()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
if (sourceHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(sourceHtml)
|
||||
val safeHref = href.replace("\"", "\\\"")
|
||||
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
||||
|
||||
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
} else if (href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
} else if (href.startsWith("#")) {
|
||||
isFootnote = true
|
||||
}
|
||||
|
||||
if (isFootnote) {
|
||||
val decodedHref = try {
|
||||
URLDecoder.decode(href, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
href
|
||||
}
|
||||
val parts = decodedHref.split('#', limit = 2)
|
||||
val pathPart = parts[0]
|
||||
val anchor = if (parts.size > 1) parts[1] else null
|
||||
|
||||
if (anchor != null) {
|
||||
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
||||
val sourceChapter =
|
||||
book.chaptersForPagination.find { it.absPath == currentChapterPath }
|
||||
if (sourceChapter != null) {
|
||||
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
URI(currentChapterPath).resolve(pathPart)
|
||||
.normalize().path
|
||||
File(book.extractionBasePath, sourceChapter.htmlFilePath)
|
||||
.readText()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
""
|
||||
}
|
||||
}
|
||||
if (sourceHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(sourceHtml)
|
||||
val safeHref = href.replace("\"", "\\\"")
|
||||
val aTag = doc.select("a[href=\"$safeHref\"]").first()
|
||||
|
||||
if (targetPath != null) {
|
||||
val targetChapter = book.chaptersForPagination.find {
|
||||
val linkType = aTag?.attr("epub:type").orEmpty()
|
||||
val linkRole = aTag?.attr("role").orEmpty()
|
||||
if (
|
||||
linkType.contains("noteref", ignoreCase = true) ||
|
||||
linkRole.contains("doc-noteref", ignoreCase = true)
|
||||
) {
|
||||
isFootnote = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
val decodedHref = try {
|
||||
URLDecoder.decode(href, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
href
|
||||
}
|
||||
val parts = decodedHref.split('#', limit = 2)
|
||||
val pathPart = parts[0]
|
||||
val anchor = if (parts.size > 1) parts[1] else null
|
||||
|
||||
if (anchor != null) {
|
||||
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
|
||||
try {
|
||||
URI(it.absPath).normalize().path == targetPath
|
||||
URI(currentChapterPath).resolve(pathPart)
|
||||
.normalize().path
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChapter != null) {
|
||||
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
||||
if (targetPath != null) {
|
||||
val targetChapter = book.chaptersForPagination.find {
|
||||
try {
|
||||
File(
|
||||
book.extractionBasePath,
|
||||
targetChapter.htmlFilePath
|
||||
).readText()
|
||||
URI(it.absPath).normalize().path == targetPath
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
false
|
||||
}
|
||||
}
|
||||
if (targetHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(targetHtml)
|
||||
val noteEl = doc.getElementById(anchor)
|
||||
if (noteEl != null) {
|
||||
footnoteHtml = noteEl.html()
|
||||
|
||||
if (targetChapter != null) {
|
||||
val targetHtml = targetChapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
File(
|
||||
book.extractionBasePath,
|
||||
targetChapter.htmlFilePath
|
||||
).readText()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
if (targetHtml.isNotEmpty()) {
|
||||
val doc = Jsoup.parse(targetHtml)
|
||||
val noteEl = doc.getElementById(anchor)
|
||||
if (noteEl != null) {
|
||||
val targetType = noteEl.attr("epub:type")
|
||||
val targetRole = noteEl.attr("role")
|
||||
val targetClass = noteEl.className()
|
||||
val targetLooksLikeFootnote =
|
||||
targetType.contains("footnote", ignoreCase = true) ||
|
||||
targetRole.contains("doc-footnote", ignoreCase = true) ||
|
||||
targetClass.contains("footnote", ignoreCase = true)
|
||||
if (isFootnote || targetLooksLikeFootnote) {
|
||||
footnoteHtml = noteEl.html()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!footnoteHtml.isNullOrBlank()) {
|
||||
onFootnoteRequested(footnoteHtml)
|
||||
isNavigatingByLink = false
|
||||
withContext(Dispatchers.Main) { onFootnoteRequested(footnoteHtml) }
|
||||
} else {
|
||||
paginator.navigateToHref(currentChapterPath, href) {
|
||||
onNavComplete(it)
|
||||
isNavigatingByLink = false
|
||||
val targetPage = (paginator as? BookPaginator)?.findStablePageForHref(currentChapterPath, href)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (targetPage != null) {
|
||||
val targetAnchor = (paginator as? BookPaginator)?.getLocatorForPage(targetPage)
|
||||
val navigationEpoch = System.currentTimeMillis()
|
||||
localExplicitNavigationAnchor = targetAnchor
|
||||
localExplicitNavigationEpoch = navigationEpoch
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"link_resolved href=$href targetPage=$targetPage anchor=$targetAnchor epoch=$navigationEpoch"
|
||||
)
|
||||
paginator.onUserScrolledTo(targetPage)
|
||||
onNavComplete(targetPage)
|
||||
} else {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).w(
|
||||
"link_failed href=$href currentChapterPath=$currentChapterPath"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) { isNavigatingByLink = false }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1359,7 +1500,7 @@ private fun findFuzzyMatch(source: String, target: String, ignoreCase: Boolean =
|
|||
return null
|
||||
}
|
||||
|
||||
private fun getHighlightOffsetsInBlock(
|
||||
internal fun getHighlightOffsetsInBlock(
|
||||
block: TextContentBlock, highlight: UserHighlight
|
||||
): IntRange? {
|
||||
if (block.cfi == null) return null
|
||||
|
|
@ -1368,6 +1509,7 @@ private fun getHighlightOffsetsInBlock(
|
|||
val parts = highlight.cfi.split('|')
|
||||
val startCfi = parts.firstOrNull() ?: highlight.cfi
|
||||
val endCfi = parts.lastOrNull()
|
||||
val isMultipartHighlight = endCfi != null && endCfi != startCfi
|
||||
|
||||
@Suppress("REDUNDANT_ELSE_IN_WHEN") val blockStartAbs = when (block) {
|
||||
is ParagraphBlock -> block.startCharOffsetInSource
|
||||
|
|
@ -1376,6 +1518,9 @@ private fun getHighlightOffsetsInBlock(
|
|||
is ListItemBlock -> block.startCharOffsetInSource
|
||||
else -> 0
|
||||
}
|
||||
val blockEndAbs = block.endCharOffsetInSource
|
||||
.takeIf { it > blockStartAbs }
|
||||
?: (blockStartAbs + block.content.text.length)
|
||||
|
||||
Timber.d(
|
||||
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
||||
|
|
@ -1419,48 +1564,28 @@ private fun getHighlightOffsetsInBlock(
|
|||
)
|
||||
}
|
||||
|
||||
var isAfterStart = false
|
||||
var isBeforeEnd = true
|
||||
|
||||
if (relevantPart == null) {
|
||||
if (startCfi.isNotEmpty()) {
|
||||
try {
|
||||
if (CfiUtils.compare(block.cfi!!, startCfi) > 0) {
|
||||
isAfterStart = true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
if (endCfi != null && endCfi != startCfi) {
|
||||
try {
|
||||
val endPath = CfiUtils.getPath(endCfi)
|
||||
val cmp = CfiUtils.compare(blockPath, endPath)
|
||||
Timber.d(" -> Comparing BlockPath ($blockPath) vs EndPath ($endPath). Result: $cmp")
|
||||
if (CfiUtils.compare(blockPath, endPath) > 0) {
|
||||
isBeforeEnd = false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d(" -> relevantPart=$relevantPart, isAfterStart=$isAfterStart, isBeforeEnd=$isBeforeEnd")
|
||||
|
||||
if (relevantPart == null && (!isAfterStart || !isBeforeEnd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val blockText = block.content.text
|
||||
val highlightText = highlight.text
|
||||
|
||||
if (blockText.isEmpty() || highlightText.isEmpty()) return null
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
|
||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||
if (startIndex == -1) {
|
||||
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
||||
val isIntermediateBlock = relevantPart == null &&
|
||||
isMultipartHighlight &&
|
||||
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
||||
|
||||
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
|
||||
|
||||
if (relevantPart == null) {
|
||||
if (!isIntermediateBlock) return null
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||
0 until blockText.length
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (relevantPart != null) {
|
||||
|
|
@ -1482,11 +1607,27 @@ private fun getHighlightOffsetsInBlock(
|
|||
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
||||
|
||||
if (startMatches || endMatches) {
|
||||
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
||||
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
||||
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
||||
val rangeStartAbs = minOf(startAbs, endAbs)
|
||||
val rangeEndAbs = maxOf(startAbs, endAbs)
|
||||
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
||||
Timber.d(
|
||||
" -> Skipping same-path split block outside highlight offsets. " +
|
||||
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
|
||||
)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
if (startMatches && startAbs != null && startAbs >= blockEndAbs) return null
|
||||
if (endMatches && endAbs != null && endAbs <= blockStartAbs) return null
|
||||
}
|
||||
var s = 0
|
||||
var e = blockText.length
|
||||
|
||||
if (startMatches) {
|
||||
val absOffset = CfiUtils.getOffset(startCfi)
|
||||
val absOffset = startAbs ?: CfiUtils.getOffset(startCfi)
|
||||
val relOffset = absOffset - blockStartAbs
|
||||
|
||||
if (relOffset < 0) {
|
||||
|
|
@ -1530,7 +1671,7 @@ private fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
|
||||
if (endMatches) {
|
||||
val absOffset = CfiUtils.getOffset(endCfi!!)
|
||||
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
||||
val relOffset = absOffset - blockStartAbs
|
||||
|
||||
Timber.d(
|
||||
|
|
@ -1559,18 +1700,16 @@ private fun getHighlightOffsetsInBlock(
|
|||
}
|
||||
}
|
||||
|
||||
if (startIndex >= 0) {
|
||||
return startIndex until (startIndex + highlightText.length)
|
||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
||||
|
||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||
if (startIndex == -1) {
|
||||
startIndex = blockText.indexOf(highlightText, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (relevantPart == null) {
|
||||
@Suppress("KotlinConstantConditions") if (isAfterStart) {
|
||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||
if (normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||
return 0 until blockText.length
|
||||
}
|
||||
}
|
||||
if (startIndex >= 0) {
|
||||
return startIndex until (startIndex + highlightText.length)
|
||||
}
|
||||
|
||||
val match = findFuzzyMatch(blockText, highlightText)
|
||||
|
|
@ -2073,6 +2212,7 @@ internal fun PaginatedReaderContent(
|
|||
uiState: PaginatedReaderUiState,
|
||||
pagerState: PagerState,
|
||||
isPageTurnAnimationEnabled: Boolean,
|
||||
isRightToLeftPagination: Boolean = false,
|
||||
effectiveBg: Color,
|
||||
effectiveText: Color,
|
||||
searchQuery: String,
|
||||
|
|
@ -2084,6 +2224,7 @@ internal fun PaginatedReaderContent(
|
|||
onGetPage: (Int) -> Page?,
|
||||
onGetChapterPath: (Int) -> String?,
|
||||
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
||||
onInternalLinkNavigated: (Int) -> Unit,
|
||||
onTap: (Offset?) -> Unit,
|
||||
isProUser: Boolean,
|
||||
isOss: Boolean,
|
||||
|
|
@ -2231,7 +2372,8 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
beyondViewportPageCount = 1
|
||||
beyondViewportPageCount = 1,
|
||||
reverseLayout = isRightToLeftPagination
|
||||
) { pageIndex ->
|
||||
val pageOffset =
|
||||
(pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
|
|
@ -2432,7 +2574,11 @@ internal fun PaginatedReaderContent(
|
|||
} else {
|
||||
currentChapterPath?.let { path ->
|
||||
onLinkClick(path, href) { targetPageIndex ->
|
||||
onInternalLinkNavigated(targetPageIndex)
|
||||
coroutineScope.launch {
|
||||
Timber.tag(TAG_STABLE_PAGE_NAV).d(
|
||||
"link_scroll targetPage=$targetPageIndex currentPage=${pagerState.currentPage}"
|
||||
)
|
||||
pagerState.scrollToPage(targetPageIndex)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import kotlin.math.abs
|
||||
|
||||
internal fun SpanStyle.withReaderLinkStyle(
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color,
|
||||
themeTextColor: Color
|
||||
): SpanStyle {
|
||||
val linkStyle = readerLinkSpanStyle(
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
existingDecoration = textDecoration
|
||||
)
|
||||
return copy(
|
||||
color = linkStyle.color,
|
||||
background = linkStyle.background,
|
||||
textDecoration = linkStyle.textDecoration
|
||||
)
|
||||
}
|
||||
|
||||
internal fun readerLinkSpanStyle(
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color,
|
||||
themeTextColor: Color,
|
||||
existingDecoration: TextDecoration? = null
|
||||
): SpanStyle {
|
||||
val background = themeBackgroundColor.takeIf { it.isSpecified }
|
||||
?: if (isDarkTheme) Color.Black else Color.White
|
||||
val text = themeTextColor.takeIf { it.isSpecified }
|
||||
?: if (isDarkTheme) Color.White else Color.Black
|
||||
val linkColor = readerLinkColorForTheme(isDarkTheme, background, text)
|
||||
val backgroundAlpha = if (background.safeLuminance() < 0.45f) 0.24f else 0.16f
|
||||
return SpanStyle(
|
||||
color = linkColor,
|
||||
background = linkColor.copy(alpha = backgroundAlpha),
|
||||
textDecoration = existingDecoration.withUnderline()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerLinkColorForTheme(
|
||||
isDarkTheme: Boolean,
|
||||
background: Color,
|
||||
text: Color
|
||||
): Color {
|
||||
val backgroundLuminance = background.safeLuminance()
|
||||
val textLuminance = text.safeLuminance()
|
||||
val candidates = if (isDarkTheme || backgroundLuminance < 0.45f) {
|
||||
listOf(
|
||||
Color(0xFF7DD3FC),
|
||||
Color(0xFF5EEAD4),
|
||||
Color(0xFFA5B4FC),
|
||||
Color(0xFFFDE68A),
|
||||
Color.White
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFF005FCC),
|
||||
Color(0xFF006D75),
|
||||
Color(0xFF7A1E52),
|
||||
Color(0xFF4A148C),
|
||||
Color(0xFF111827)
|
||||
)
|
||||
}
|
||||
return candidates.firstOrNull {
|
||||
it.contrastRatio(background) >= 4.5f && abs(it.safeLuminance() - textLuminance) >= 0.08f
|
||||
} ?: candidates.maxByOrNull { it.contrastRatio(background) }
|
||||
?: if (isDarkTheme) Color(0xFF7DD3FC) else Color(0xFF005FCC)
|
||||
}
|
||||
|
||||
private fun TextDecoration?.withUnderline(): TextDecoration {
|
||||
val current = this ?: TextDecoration.None
|
||||
val decorations = mutableListOf<TextDecoration>()
|
||||
if (current.contains(TextDecoration.LineThrough)) decorations += TextDecoration.LineThrough
|
||||
decorations += TextDecoration.Underline
|
||||
return TextDecoration.combine(decorations)
|
||||
}
|
||||
|
||||
private fun Color.contrastRatio(other: Color): Float {
|
||||
val lighter = maxOf(safeLuminance(), other.safeLuminance())
|
||||
val darker = minOf(safeLuminance(), other.safeLuminance())
|
||||
return (lighter + 0.05f) / (darker + 0.05f)
|
||||
}
|
||||
|
||||
private fun Color.safeLuminance(): Float {
|
||||
return if (isSpecified) luminance() else 0f
|
||||
}
|
||||
|
|
@ -118,6 +118,17 @@ private fun AnnotatedString.applyReaderThemeForDisplay(
|
|||
}
|
||||
addStringAnnotation(range.tag, item, range.start, range.end)
|
||||
}
|
||||
this@applyReaderThemeForDisplay.getStringAnnotations("URL", 0, this@applyReaderThemeForDisplay.length).forEach { range ->
|
||||
addStyle(
|
||||
readerLinkSpanStyle(
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor
|
||||
),
|
||||
range.start,
|
||||
range.end
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
internal suspend fun resolveStableChapterStartPage(
|
||||
chapterIndex: Int,
|
||||
chapterCount: Int,
|
||||
pageCountsAreAccurate: Boolean,
|
||||
chapterStartPage: (Int) -> Int?,
|
||||
isChapterFinalized: (Int) -> Boolean,
|
||||
ensureChapterPaginated: suspend (Int) -> Boolean
|
||||
): Int? {
|
||||
if (chapterIndex !in 0 until chapterCount) return null
|
||||
|
||||
if (!pageCountsAreAccurate) {
|
||||
for (prefixChapter in 0 until chapterIndex) {
|
||||
if (!isChapterFinalized(prefixChapter)) {
|
||||
val ready = ensureChapterPaginated(prefixChapter)
|
||||
if (!ready) return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chapterStartPage(chapterIndex) ?: if (chapterIndex == 0) 0 else null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue