Bug fixes (#319)

* Fix TTS speaker persistence

* optimized redundant WebView updates

* Bumped version to 1.0.48
This commit is contained in:
Aryan 2026-05-16 22:59:53 +05:30 committed by GitHub
parent 70c272baa7
commit bcf34af719
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 781 additions and 173 deletions

View file

@ -99,6 +99,7 @@ import java.io.BufferedReader
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
@ -113,6 +114,68 @@ private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"LinkNavBridge"
)
private class WebViewRuntimeApplierState {
var fontCss: String? = null
var styleSignature: String? = null
var tocFragmentsJson: String? = null
var highlightsJson: String? = null
private var unchangedUpdateCount = 0
private var pendingUpdateCount = 0
private var lastUnchangedUpdateLogAt = 0L
private var lastPendingUpdateLogAt = 0L
fun logApplied(
chapterTitle: String,
fontCssChanged: Boolean,
styleChanged: Boolean,
tocFragmentsChanged: Boolean,
highlightsChanged: Boolean
) {
if (unchangedUpdateCount > 0) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"androidUpdate resumeAfterUnchanged count=$unchangedUpdateCount chapter='$chapterTitle'"
)
unchangedUpdateCount = 0
}
if (pendingUpdateCount > 0) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"androidUpdate resumeAfterPending count=$pendingUpdateCount chapter='$chapterTitle'"
)
pendingUpdateCount = 0
}
lastUnchangedUpdateLogAt = System.currentTimeMillis()
lastPendingUpdateLogAt = lastUnchangedUpdateLogAt
Timber.tag(TAG_VERTICAL_JITTER).d(
"androidUpdate applied chapter='$chapterTitle' fontCss=$fontCssChanged " +
"style=$styleChanged toc=$tocFragmentsChanged highlights=$highlightsChanged"
)
}
fun logUnchanged(chapterTitle: String) {
unchangedUpdateCount++
val now = System.currentTimeMillis()
if (now - lastUnchangedUpdateLogAt >= 1000L) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"androidUpdate unchanged count=$unchangedUpdateCount chapter='$chapterTitle'"
)
unchangedUpdateCount = 0
lastUnchangedUpdateLogAt = now
}
}
fun logPending(chapterTitle: String) {
pendingUpdateCount++
val now = System.currentTimeMillis()
if (now - lastPendingUpdateLogAt >= 1000L) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"androidUpdate pendingPageLoad count=$pendingUpdateCount chapter='$chapterTitle'"
)
pendingUpdateCount = 0
lastPendingUpdateLogAt = now
}
}
}
private fun WebView.releaseReaderResources() {
try {
stopLoading()
@ -523,6 +586,9 @@ fun ChapterWebView(
currentFontFamily,
currentTextAlign
) {
val runtimeApplierState = remember { WebViewRuntimeApplierState() }
var isReaderRuntimeReady by remember { mutableStateOf(false) }
AndroidView(
factory = { ctx ->
Timber.d(
@ -556,6 +622,7 @@ fun ChapterWebView(
}).apply {
localWebViewRef = this
onWebViewInstanceCreated(this)
val debugWebViewId = System.identityHashCode(this).toString(16)
addJavascriptInterface(
PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
@ -671,6 +738,11 @@ fun ChapterWebView(
.d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}")
}
message.startsWith("$TAG_VERTICAL_JITTER:") -> {
Timber.tag(TAG_VERTICAL_JITTER)
.d("webView=$debugWebViewId chapter='$chapterTitle' JS -> ${message.substringAfter("$TAG_VERTICAL_JITTER: ")}")
}
else -> {
Timber.d(
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
@ -818,11 +890,11 @@ fun ChapterWebView(
)
view?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
"javascript:window.CURRENT_HIGHLIGHTS = '${
escapeJsString(
highlightsJson
)
}');", null
}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null
)
val fontCss = getFontCssInjection().replace("\n", " ")
@ -845,6 +917,20 @@ fun ChapterWebView(
currentFontFamily.fontFamilyName
}
runtimeApplierState.fontCss = combinedCss
runtimeApplierState.styleSignature = listOf(
currentFontSize,
currentLineHeight,
fontNameForJs,
currentTextAlign.cssValue,
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentVerticalMargin
).joinToString(separator = "|")
runtimeApplierState.tocFragmentsJson = fragmentsJson
runtimeApplierState.highlightsJson = highlightsJson
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
@ -956,6 +1042,9 @@ fun ChapterWebView(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
isReaderRuntimeReady = true
Timber.tag(TAG_VERTICAL_JITTER)
.d("androidPageFinished runtimeReady webView=$debugWebViewId chapter='$chapterTitle'")
}
}
settings.apply {
@ -983,6 +1072,7 @@ fun ChapterWebView(
},
modifier = Modifier.fillMaxSize(),
onRelease = { releasedWebView ->
isReaderRuntimeReady = false
if (localWebViewRef === releasedWebView) {
localWebViewRef = null
}
@ -992,42 +1082,84 @@ fun ChapterWebView(
releasedWebView.releaseReaderResources()
},
update = { webView ->
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
localWebViewRef = webView
onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
if (!isReaderRuntimeReady) {
runtimeApplierState.logPending(chapterTitle)
} else {
currentFontFamily.fontFamilyName
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
} else {
currentFontFamily.fontFamilyName
}
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
val styleSignature = listOf(
currentFontSize,
currentLineHeight,
fontNameForJs,
currentTextAlign.cssValue,
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentVerticalMargin
).joinToString(separator = "|")
val fontCssChanged = runtimeApplierState.fontCss != combinedCss
val styleChanged = runtimeApplierState.styleSignature != styleSignature
val tocFragmentsChanged = runtimeApplierState.tocFragmentsJson != fragmentsJson
val highlightsChanged = runtimeApplierState.highlightsJson != highlightsJson
if (fontCssChanged || styleChanged || tocFragmentsChanged || highlightsChanged) {
runtimeApplierState.logApplied(
chapterTitle = chapterTitle,
fontCssChanged = fontCssChanged,
styleChanged = styleChanged,
tocFragmentsChanged = tocFragmentsChanged,
highlightsChanged = highlightsChanged
)
} else {
runtimeApplierState.logUnchanged(chapterTitle)
}
if (fontCssChanged) {
runtimeApplierState.fontCss = combinedCss
val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
}
if (tocFragmentsChanged) {
runtimeApplierState.tocFragmentsJson = fragmentsJson
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
webView.evaluateJavascript(
"javascript:window.setTocFragments($fragmentsJson);",
null
)
}
if (styleChanged) {
runtimeApplierState.styleSignature = styleSignature
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)
}
if (highlightsChanged) {
runtimeApplierState.highlightsJson = highlightsJson
val escapedHighlights = escapeJsString(highlightsJson)
webView.evaluateJavascript(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null
)
}
}
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
webView.evaluateJavascript(
"javascript:window.setTocFragments($fragmentsJson);",
null
)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)
val escapedHighlights = escapeJsString(highlightsJson)
webView.evaluateJavascript(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null
)
}
)
}

View file

@ -252,6 +252,7 @@ private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
@ -3574,6 +3575,9 @@ fun EpubReaderHost(
""".trimIndent()
val chapterToRender = chapters[targetChapterIndex]
fun isCurrentRenderedChapter(): Boolean =
targetChapterIndex == currentChapterIndex
val chapterKeyForWebView =
remember(
chapterToRender.htmlFilePath,
@ -3629,8 +3633,8 @@ fun EpubReaderHost(
}
}
val currentChapterTocFragments = remember(epubBook.tableOfContents, currentChapterIndex) {
val chapterPath = chapters.getOrNull(currentChapterIndex)?.absPath
val currentChapterTocFragments = remember(epubBook.tableOfContents, targetChapterIndex) {
val chapterPath = chapters.getOrNull(targetChapterIndex)?.absPath
epubBook.tableOfContents
.filter { it.absolutePath == chapterPath && it.fragmentId != null }
.mapNotNull { it.fragmentId }
@ -3683,42 +3687,48 @@ fun EpubReaderHost(
}
},
onChapterInitiallyScrolled = {
val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
initialScrollTargetForChapter = null
cfiToLoad = null
fragmentToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
isWebViewReady = true
if (wasCfiScroll) {
scope.launch {
delay(1000L)
isChapterReadyForBookmarkCheck = true
Timber.d("Auto-save enabled after CFI scroll delay.")
}
if (!isCurrentRenderedChapter()) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
)
} else {
isChapterReadyForBookmarkCheck = true
Timber.d("Auto-save enabled immediately.")
}
val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
initialScrollTargetForChapter = null
cfiToLoad = null
fragmentToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
isWebViewReady = true
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
logTtsChapterDiag("Auto-starting TTS extraction for chapter load")
hasRequestedExtractionForThisChapter = true
scope.launch {
delay(200)
webViewRefForTts?.evaluateJavascript(
"javascript:TtsBridgeHelper.extractAndRelayText();",
null
)
if (wasCfiScroll) {
scope.launch {
delay(1000L)
isChapterReadyForBookmarkCheck = true
Timber.d("Auto-save enabled after CFI scroll delay.")
}
} else {
isChapterReadyForBookmarkCheck = true
Timber.d("Auto-save enabled immediately.")
}
}
if (isAutoScrollModeActive && isAutoScrollPlaying) {
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
triggerAutoScrollTempPause(1000L)
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
logTtsChapterDiag("Auto-starting TTS extraction for chapter load")
hasRequestedExtractionForThisChapter = true
scope.launch {
delay(200)
webViewRefForTts?.evaluateJavascript(
"javascript:TtsBridgeHelper.extractAndRelayText();",
null
)
}
}
if (isAutoScrollModeActive && isAutoScrollPlaying) {
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
triggerAutoScrollTempPause(1000L)
}
}
},
onTap = {
@ -3886,22 +3896,28 @@ fun EpubReaderHost(
},
tocFragments = currentChapterTocFragments,
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
currentScrollYPosition = scrollY
currentScrollHeightValue = scrollHeight
currentClientHeightValue = clientHeight
if (!isCurrentRenderedChapter()) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"ignored stale scrollState rendered=$targetChapterIndex current=$currentChapterIndex y=$scrollY height=$scrollHeight chapter='${chapterToRender.title}'"
)
} else {
currentScrollYPosition = scrollY
currentScrollHeightValue = scrollHeight
currentClientHeightValue = clientHeight
if (activeFragmentId != fragId) {
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
activeFragmentId = fragId
}
if (activeFragmentId != fragId) {
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
activeFragmentId = fragId
}
if (volumeScrollEnabled && !searchState.isSearchActive) {
volumeScrollFocusDebounceJob.value?.cancel()
volumeScrollFocusDebounceJob.value = scope.launch {
delay(300L)
if (isActive) {
containerFocusRequester.requestFocus()
Timber.d("Refocusing container after scroll to re-enable volume keys.")
if (volumeScrollEnabled && !searchState.isSearchActive) {
volumeScrollFocusDebounceJob.value?.cancel()
volumeScrollFocusDebounceJob.value = scope.launch {
delay(300L)
if (isActive) {
containerFocusRequester.requestFocus()
Timber.d("Refocusing container after scroll to re-enable volume keys.")
}
}
}
}
@ -4059,7 +4075,13 @@ fun EpubReaderHost(
}
},
onWebViewInstanceCreated = { webView ->
webViewRefForTts = webView
if (isCurrentRenderedChapter()) {
webViewRefForTts = webView
} else {
Timber.tag(TAG_VERTICAL_JITTER).d(
"ignored stale webViewRef rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
)
}
webView.evaluateJavascript(
"javascript:window.setViewportPadding(${topPaddingPx}, 0);",
null
@ -4386,25 +4408,37 @@ fun EpubReaderHost(
)
},
onTopChunkUpdated = { chunkIndex ->
topVisibleChunkIndex = chunkIndex
if (isCurrentRenderedChapter()) {
topVisibleChunkIndex = chunkIndex
} else {
Timber.tag(TAG_VERTICAL_JITTER).d(
"ignored stale topChunk rendered=$targetChapterIndex current=$currentChapterIndex chunk=$chunkIndex chapter='${chapterToRender.title}'"
)
}
},
initialHtmlContent = initialHtml,
baseUrl = baseUrl,
totalChunks = chapterChunks.size,
initialChunkIndex = loadUpToChunkIndex,
onChunkRequested = { index ->
val chunkContent = chapterChunks.getOrNull(index)
if (chunkContent != null) {
loadedChunkCount =
max(loadedChunkCount, index + 1)
val escapedContent =
escapeJsString(chunkContent)
val jsCommand =
"javascript:window.virtualization.appendChunk($index, '$escapedContent');"
webViewRefForTts?.evaluateJavascript(
jsCommand,
null
if (!isCurrentRenderedChapter()) {
Timber.tag(TAG_VERTICAL_JITTER).d(
"ignored stale chunkRequest rendered=$targetChapterIndex current=$currentChapterIndex chunk=$index chapter='${chapterToRender.title}'"
)
} else {
val chunkContent = chapterChunks.getOrNull(index)
if (chunkContent != null) {
loadedChunkCount =
max(loadedChunkCount, index + 1)
val escapedContent =
escapeJsString(chunkContent)
val jsCommand =
"javascript:window.virtualization.appendChunk($index, '$escapedContent');"
webViewRefForTts?.evaluateJavascript(
jsCommand,
null
)
}
}
},
)