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

@ -1,5 +1,11 @@
// epub_reader.js
(function () {
var READER_VERTICAL_JITTER_TAG = "EpubVerticalJitter";
function logVerticalJitter(message) {
console.log(READER_VERTICAL_JITTER_TAG + ": " + message);
}
window.logReaderVerticalJitter = window.logReaderVerticalJitter || logVerticalJitter;
function applyMobileOptimizationsAndSelection() {
var viewport = document.querySelector("meta[name=viewport]");
@ -870,6 +876,30 @@
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0;
var styleSignature = [
newFontSize,
newLineHeight,
fontFamily || "",
textAlign || "",
newGap,
newImageSize,
newHorizontalMargin,
newVerticalMargin,
].join("|");
if (window.__readerStyleSignature === styleSignature && dynamicStyleElement.innerHTML.trim().length > 0) {
logVerticalJitter(
"jsStyleSkip unchanged scrollY=" +
Math.round(window.scrollY || window.pageYOffset || 0) +
" signature=" +
styleSignature,
);
return false;
}
var scrollYBeforeStyle = Math.round(window.scrollY || window.pageYOffset || 0);
var scrollHeightBeforeStyle = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight));
window.__readerStyleSignature = styleSignature;
rememberReaderImageAnchors();
var fontCss = "";
@ -964,6 +994,16 @@
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
applyReaderImageAnchors();
setTimeout(applyReaderImageAnchors, 80);
logVerticalJitter(
"jsStyleApply scrollY=" +
scrollYBeforeStyle +
" scrollHeight=" +
scrollHeightBeforeStyle +
"->" +
Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)) +
" signature=" +
styleSignature,
);
setTimeout(
function () {
@ -992,18 +1032,105 @@
} else if (window.reportScrollState) {
setTimeout(window.reportScrollState, 60);
}
return true;
};
window.TOC_FRAGMENTS = window.TOC_FRAGMENTS || [];
window.setTocFragments = function (jsonArray) {
var signature = JSON.stringify(jsonArray || []);
if (window.__readerTocFragmentsSignature === signature) {
logVerticalJitter("tocSkip unchanged count=" + ((jsonArray && jsonArray.length) || 0));
return false;
}
window.__readerTocFragmentsSignature = signature;
console.log("FRAG_NAV_DEBUG: window.setTocFragments called with " + jsonArray.length + " items.");
logVerticalJitter("tocApply count=" + jsonArray.length);
window.TOC_FRAGMENTS = jsonArray;
// Immediate audit and report
window.auditTocFragments();
window.reportScrollState();
return true;
};
var lastVerticalJitterScrollLogAt = 0;
var jitterProbeLastScrollY = null;
var jitterProbeLastScrollHeight = null;
var jitterProbeLastTime = 0;
var jitterProbeTrend = 0;
var jitterProbeLastAnomalyAt = 0;
var jitterProbeTouchActive = false;
var jitterProbeLastTouchY = null;
var jitterProbeExpectedScrollDirection = 0;
function updateVerticalJitterScrollProbe(scrollY, scrollHeight, clientHeight) {
var now = Date.now();
if (jitterProbeLastScrollY === null) {
jitterProbeLastScrollY = scrollY;
jitterProbeLastScrollHeight = scrollHeight;
jitterProbeLastTime = now;
return;
}
var deltaY = scrollY - jitterProbeLastScrollY;
var deltaHeight = scrollHeight - jitterProbeLastScrollHeight;
var elapsedMs = now - jitterProbeLastTime;
var direction = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
var wasOppositeToTouch =
jitterProbeTouchActive &&
jitterProbeExpectedScrollDirection !== 0 &&
direction !== 0 &&
direction !== jitterProbeExpectedScrollDirection;
var wasOppositeToTrend =
!jitterProbeTouchActive &&
jitterProbeTrend !== 0 &&
direction !== 0 &&
direction !== jitterProbeTrend;
var isSmallReverse = Math.abs(deltaY) >= 2 && Math.abs(deltaY) <= 96;
var canLogAnomaly = now - jitterProbeLastAnomalyAt >= 120;
if (deltaHeight !== 0 && canLogAnomaly) {
logVerticalJitter(
"scrollHeightChange y=" +
jitterProbeLastScrollY +
"->" +
scrollY +
" height=" +
jitterProbeLastScrollHeight +
"->" +
scrollHeight +
" touch=" +
jitterProbeTouchActive,
);
jitterProbeLastAnomalyAt = now;
} else if ((wasOppositeToTouch || wasOppositeToTrend) && isSmallReverse && deltaHeight === 0 && canLogAnomaly) {
logVerticalJitter(
"scrollReverseSmall y=" +
jitterProbeLastScrollY +
"->" +
scrollY +
" dy=" +
deltaY +
" elapsedMs=" +
elapsedMs +
" expected=" +
jitterProbeExpectedScrollDirection +
" trend=" +
jitterProbeTrend +
" clientHeight=" +
clientHeight,
);
jitterProbeLastAnomalyAt = now;
}
if (direction !== 0) {
jitterProbeTrend = direction;
}
jitterProbeLastScrollY = scrollY;
jitterProbeLastScrollHeight = scrollHeight;
jitterProbeLastTime = now;
}
window.reportScrollState = function () {
if (typeof PageInfoReporter !== "undefined" && PageInfoReporter.updateScrollState) {
var scrollY = Math.round(window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0);
@ -1047,6 +1174,21 @@
}
PageInfoReporter.updateScrollState(scrollY, scrollHeight, clientHeight, activeFragment);
updateVerticalJitterScrollProbe(scrollY, scrollHeight, clientHeight);
var now = Date.now();
if (now - lastVerticalJitterScrollLogAt >= 1000) {
logVerticalJitter(
"scrollReport y=" +
scrollY +
" scrollHeight=" +
scrollHeight +
" clientHeight=" +
clientHeight +
" activeFragment=" +
(activeFragment || ""),
);
lastVerticalJitterScrollLogAt = now;
}
}
window.reportTopChunk();
@ -1086,6 +1228,53 @@
);
};
window.addEventListener(
"touchstart",
function (event) {
var touch = event.touches && event.touches[0];
jitterProbeTouchActive = true;
jitterProbeLastTouchY = touch ? touch.clientY : null;
jitterProbeExpectedScrollDirection = 0;
},
{ passive: true },
);
window.addEventListener(
"touchmove",
function (event) {
var touch = event.touches && event.touches[0];
if (!touch || jitterProbeLastTouchY === null) return;
var fingerDeltaY = touch.clientY - jitterProbeLastTouchY;
if (Math.abs(fingerDeltaY) >= 1) {
jitterProbeExpectedScrollDirection = fingerDeltaY < 0 ? 1 : -1;
}
jitterProbeLastTouchY = touch.clientY;
},
{ passive: true },
);
window.addEventListener(
"touchend",
function () {
jitterProbeLastTouchY = null;
setTimeout(function () {
jitterProbeTouchActive = false;
jitterProbeExpectedScrollDirection = 0;
}, 250);
},
{ passive: true },
);
window.addEventListener(
"touchcancel",
function () {
jitterProbeTouchActive = false;
jitterProbeLastTouchY = null;
jitterProbeExpectedScrollDirection = 0;
},
{ passive: true },
);
let scrollThrottleTimeout = null;
let lastScrollTime = 0;
@ -2364,6 +2553,11 @@
(function () {
// --- VIRTUALIZATION LOGIC ---
if (window.virtualization) return;
const logVerticalJitter =
window.logReaderVerticalJitter ||
function (message) {
console.log("EpubVerticalJitter: " + message);
};
let currentBottomChunkIndex = 0;
let totalChunks = 0;
@ -2378,6 +2572,16 @@
init: function (initialChunkIndex, total) {
console.log(`Virtualization: Init with ${total} chunks. Anchor: ${initialChunkIndex}`);
logVerticalJitter(
"virtInit total=" +
total +
" anchor=" +
initialChunkIndex +
" scrollY=" +
Math.round(window.scrollY || window.pageYOffset || 0) +
" scrollHeight=" +
Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)),
);
this.totalChunks = total;
this.chunksData = new Array(total).fill(null);
this.chunkHeights = new Array(total).fill(0);
@ -2413,6 +2617,7 @@
if (entry.isIntersecting) {
if (!this.chunksData[idx]) {
logVerticalJitter("virtRequestChunk idx=" + idx);
if (window.ContentBridge && window.ContentBridge.requestChunk) {
window.ContentBridge.requestChunk(idx);
}
@ -2428,6 +2633,18 @@
if (div.getBoundingClientRect().top < 0) {
scrollAdjust += (newHeight - oldHeight);
}
logVerticalJitter(
"virtRestore idx=" +
idx +
" oldHeight=" +
Math.round(oldHeight) +
" newHeight=" +
Math.round(newHeight) +
" top=" +
Math.round(div.getBoundingClientRect().top) +
" pendingAdjust=" +
Math.round(scrollAdjust),
);
if (window.CURRENT_HIGHLIGHTS) {
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
}
@ -2442,12 +2659,29 @@
div.style.height = oldHeight + "px";
div.innerHTML = "";
domChanged = true;
logVerticalJitter(
"virtUnload idx=" +
idx +
" placeholderHeight=" +
Math.round(oldHeight) +
" top=" +
Math.round(div.getBoundingClientRect().top),
);
}
}
});
if (scrollAdjust !== 0) {
let before = Math.round(window.scrollY || window.pageYOffset || 0);
window.scrollBy(0, scrollAdjust);
logVerticalJitter(
"virtScrollAdjust dy=" +
Math.round(scrollAdjust) +
" scrollY=" +
before +
"->" +
Math.round(window.scrollY || window.pageYOffset || 0),
);
}
if (domChanged && window.reportScrollState) {
@ -2464,6 +2698,14 @@
appendChunk: function (index, htmlContent) {
console.log(`Virtualization: Receiving chunk ${index} from Kotlin`);
logVerticalJitter(
"virtAppend idx=" +
index +
" existingData=" +
Boolean(Array.isArray(this.chunksData) && this.chunksData[index]) +
" scrollY=" +
Math.round(window.scrollY || window.pageYOffset || 0),
);
if (Array.isArray(this.chunksData)) {
this.chunksData[index] = htmlContent;
@ -2482,7 +2724,18 @@
}
if (div.getBoundingClientRect().bottom < 0) {
let before = Math.round(window.scrollY || window.pageYOffset || 0);
window.scrollBy(0, newHeight - oldHeight);
logVerticalJitter(
"virtAppendScrollAdjust idx=" +
index +
" dy=" +
Math.round(newHeight - oldHeight) +
" scrollY=" +
before +
"->" +
Math.round(window.scrollY || window.pageYOffset || 0),
);
}
if (window.reportScrollState) {

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
)
}
}
},
)

View file

@ -13,6 +13,8 @@ import android.print.PrintDocumentInfo
import android.provider.OpenableColumns
import android.util.LruCache
import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -135,6 +137,25 @@ internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean):
return "${safeBase}${suffix}_${shortId}.pdf"
}
internal fun hasExportablePdfAnnotations(
annotations: Map<Int, List<PdfAnnotation>>,
textBoxes: List<PdfTextBox>,
highlights: List<PdfUserHighlight>
): Boolean {
return annotations.any { (_, pageAnnotations) -> pageAnnotations.isNotEmpty() } ||
textBoxes.isNotEmpty() ||
highlights.isNotEmpty()
}
internal fun shouldShowPdfAnnotationExportChoice(
sidecarsReady: Boolean,
annotations: Map<Int, List<PdfAnnotation>>,
textBoxes: List<PdfTextBox>,
highlights: List<PdfUserHighlight>
): Boolean {
return !sidecarsReady || hasExportablePdfAnnotations(annotations, textBoxes, highlights)
}
internal fun getFastFileId(context: Context, uri: Uri): String {
var result = uri.toString()
try {

View file

@ -2256,6 +2256,91 @@ fun PdfViewerScreen(
var showShareDialog by remember { mutableStateOf(false) }
var showSaveDialog by remember { mutableStateOf(false) }
var isShareLoading by remember { mutableStateOf(false) }
val shouldShowAnnotationExportChoice = shouldShowPdfAnnotationExportChoice(
sidecarsReady = sidecarsReadyForCurrentBook,
annotations = visibleAllAnnotations,
textBoxes = visibleTextBoxes,
highlights = visibleUserHighlights
)
val launchOriginalSaveCopy: () -> Unit = {
pendingSaveMode = SaveMode.ORIGINAL
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = false
)
saveLauncher.launch(suggestedName)
}
val launchAnnotatedSaveCopy: () -> Unit = {
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
}
val shareOriginalPdf: () -> Unit = {
isShareLoading = true
val filename = getSuggestedFilename(
originalFileName, isAnnotated = false
)
coroutineScope.launch {
try {
viewModel.sharePdf(
activityContext = context,
sourceUri = pdfUri,
annotations = emptyMap(),
includeAnnotations = false,
filename = filename
)
} finally {
isShareLoading = false
}
}
}
val shareAnnotatedPdf: () -> Unit = {
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
coroutineScope.launch {
try {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = visibleAllAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = visibleTextBoxes,
highlights = visibleUserHighlights,
includeAnnotations = true,
filename = filename,
bookId = currentBookId
)
} finally {
isShareLoading = false
}
}
}
val requestSaveCopy: () -> Unit = {
if (shouldShowAnnotationExportChoice) {
showSaveDialog = true
} else {
launchOriginalSaveCopy()
}
}
val requestShare: () -> Unit = {
if (shouldShowAnnotationExportChoice) {
showShareDialog = true
} else {
shareOriginalPdf()
}
}
var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) }
@ -5358,8 +5443,8 @@ fun PdfViewerScreen(
}
}
},
onShare = { showShareDialog = true },
onSaveCopy = { showSaveDialog = true },
onShare = requestShare,
onSaveCopy = requestSaveCopy,
onPrint = onPrintDocument,
onTabClick = { tabBookId ->
coroutineScope.launch {
@ -7030,11 +7115,7 @@ fun PdfViewerScreen(
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
launchAnnotatedSaveCopy()
}) { Text(stringResource(R.string.action_with_annotations)) }
}
@ -7044,11 +7125,7 @@ fun PdfViewerScreen(
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ORIGINAL
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = false
)
saveLauncher.launch(suggestedName)
launchOriginalSaveCopy()
}) { Text(stringResource(R.string.action_original)) }
Spacer(Modifier.width(8.dp))
@ -7072,27 +7149,7 @@ fun PdfViewerScreen(
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = visibleAllAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = visibleTextBoxes,
highlights = visibleUserHighlights,
includeAnnotations = true,
filename = filename,
bookId = currentBookId
)
isShareLoading = false
}
shareAnnotatedPdf()
}) { Text(stringResource(R.string.action_with_annotations)) }
}
@ -7102,20 +7159,7 @@ fun PdfViewerScreen(
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
val filename = getSuggestedFilename(
originalFileName, isAnnotated = false
)
coroutineScope.launch {
viewModel.sharePdf(
activityContext = context,
sourceUri = pdfUri,
annotations = emptyMap(),
includeAnnotations = false,
filename = filename
)
isShareLoading = false
}
shareOriginalPdf()
}) { Text(stringResource(R.string.action_original)) }
Spacer(Modifier.width(8.dp))
TextButton(onClick = { showShareDialog = false }) {

View file

@ -30,8 +30,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
@ -54,19 +52,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
private const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TTS_SPEAKER_KEY = "tts_speaker"
private fun saveSpeaker(context: Context, speakerId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_SPEAKER_KEY, speakerId) }
}
private fun loadSpeaker(context: Context): String {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID
}
@OptIn(UnstableApi::class)
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -101,7 +86,7 @@ class TtsController(context: Context) : Player.Listener {
private var pollingJob: Job? = null
init {
val initialSpeakerId = loadSpeaker(this.context)
val initialSpeakerId = loadTtsSpeaker(this.context)
_ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId)
}
@ -236,11 +221,12 @@ class TtsController(context: Context) : Player.Listener {
@Suppress("unused")
fun changeSpeaker(speakerId: String) {
Timber.d("UI sending CHANGE_SPEAKER command.")
saveSpeaker(context, speakerId)
_ttsState.value = _ttsState.value.copy(speakerId = speakerId)
val safeSpeakerId = normalizeTtsSpeakerId(speakerId)
saveTtsSpeaker(context, safeSpeakerId)
_ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId)
val args = Bundle().apply {
putString(KEY_SPEAKER_ID, speakerId)
putString(KEY_SPEAKER_ID, safeSpeakerId)
}
mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args)
}

View file

@ -130,15 +130,22 @@ class TtsPlaybackManager(
val ttsMode: String = TtsMode.CLOUD.name
)
private val _ttsState = MutableStateFlow(TtsState())
private val initialSpeakerId = loadTtsSpeaker(appContext)
private val initialTtsMode = loadTtsMode(appContext)
private val _ttsState = MutableStateFlow(
TtsState(
speakerId = initialSpeakerId,
ttsMode = initialTtsMode.name
)
)
private var textChunks: List<TtsChunk> = emptyList()
private val audioFiles = java.util.concurrent.ConcurrentHashMap<Int, File>()
private var currentSpeakerId = DEFAULT_SPEAKER_ID
private var currentSpeakerId = initialSpeakerId
private var bookTitle: String? = null
private var chapterTitle: String? = null
private var coverImageUri: String? = null
private var currentTtsMode = TtsMode.CLOUD
private var currentTtsMode = initialTtsMode
private var chapterIndex: Int? = null
private var totalChapters: Int? = null
@ -157,6 +164,12 @@ class TtsPlaybackManager(
fun setMediaSession(session: MediaSession) {
this.mediaSession = session
session.setCustomLayout(
listOf(
createStateButton(_ttsState.value),
createStopCommandButton()
)
)
}
override fun onConnect(
@ -422,8 +435,10 @@ class TtsPlaybackManager(
onPlaybackSessionPreparing(bookTitle, chapterTitle)
val effectiveSpeakerId = normalizeTtsSpeakerId(speakerId)
textChunks = chunks
currentSpeakerId = speakerId
currentSpeakerId = effectiveSpeakerId
currentTtsMode = ttsMode
this.bookTitle = bookTitle
this.chapterTitle = chapterTitle
@ -443,7 +458,7 @@ class TtsPlaybackManager(
currentChunkIndex = -1,
totalChunks = chunks.size,
bookProgressPercent = calculateBookProgressPercent(-1),
speakerId = speakerId,
speakerId = effectiveSpeakerId,
playbackSource = playbackSource,
ttsMode = ttsMode.name,
currentText = if (continueSession) _ttsState.value.currentText else null
@ -480,10 +495,12 @@ class TtsPlaybackManager(
}
private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return
currentSpeakerId = newSpeakerId
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
val safeSpeakerId = normalizeTtsSpeakerId(newSpeakerId)
if (currentSpeakerId == safeSpeakerId) return
currentSpeakerId = safeSpeakerId
saveTtsSpeaker(appContext, safeSpeakerId)
_ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId)
Timber.d("Speaker changed to $safeSpeakerId (pending next start)")
}
private fun currentChunkIndexFromPlayer(): Int {
@ -681,7 +698,11 @@ class TtsPlaybackManager(
preparationJob?.cancel()
wordTrackingJob?.cancel()
if (clearState) {
val finalState = TtsState(sessionEndedByStop = userInitiated)
val finalState = TtsState(
sessionEndedByStop = userInitiated,
speakerId = currentSpeakerId,
ttsMode = currentTtsMode.name
)
_ttsState.value = finalState
mediaSession?.let { session ->
val layout = listOf(

View file

@ -25,6 +25,7 @@ import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CoroutineScope
@ -42,6 +43,8 @@ const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL
const val TTS_CHUNK_MAX_LENGTH = 250
const val DEFAULT_SPEAKER_ID = "Aoede"
internal const val TTS_SETTINGS_PREFS_NAME = "epub_reader_settings"
internal const val TTS_SPEAKER_KEY = "tts_speaker"
data class GeminiVoice(val id: String, val name: String, val description: String)
@ -78,6 +81,21 @@ val GEMINI_TTS_SPEAKERS = listOf(
GeminiVoice("Sulafat", "Sulafat", "Warn, Middle pitch"),
)
internal fun normalizeTtsSpeakerId(speakerId: String?): String {
val cleanSpeakerId = speakerId?.takeIf { it.isNotBlank() } ?: return DEFAULT_SPEAKER_ID
return cleanSpeakerId.takeIf { id -> GEMINI_TTS_SPEAKERS.any { it.id == id } } ?: DEFAULT_SPEAKER_ID
}
internal fun saveTtsSpeaker(context: Context, speakerId: String) {
val prefs = context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_SPEAKER_KEY, normalizeTtsSpeakerId(speakerId)) }
}
internal fun loadTtsSpeaker(context: Context): String {
val prefs = context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return normalizeTtsSpeakerId(prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID))
}
data class TtsChapterCacheInfo(
val chapterTitle: String,
val chunkCount: Int,