Bug fixes (#319)
* Fix TTS speaker persistence * optimized redundant WebView updates * Bumped version to 1.0.48
This commit is contained in:
parent
70c272baa7
commit
bcf34af719
11 changed files with 781 additions and 173 deletions
|
|
@ -57,8 +57,8 @@ android {
|
|||
applicationId = "com.aryan.reader"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 51
|
||||
versionName = "1.0.47"
|
||||
versionCode = 52
|
||||
versionName = "1.0.48"
|
||||
|
||||
resourceConfigurations += configuredAppLocaleTags()
|
||||
.map { it.toAndroidResourceConfiguration() }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,17 +1082,16 @@ fun ChapterWebView(
|
|||
releasedWebView.releaseReaderResources()
|
||||
},
|
||||
update = { webView ->
|
||||
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
|
||||
localWebViewRef = webView
|
||||
onWebViewInstanceCreated(webView)
|
||||
if (!isReaderRuntimeReady) {
|
||||
runtimeApplierState.logPending(chapterTitle)
|
||||
} else {
|
||||
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) {
|
||||
|
|
@ -1011,24 +1100,67 @@ fun ChapterWebView(
|
|||
currentFontFamily.fontFamilyName
|
||||
}
|
||||
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +3687,11 @@ fun EpubReaderHost(
|
|||
}
|
||||
},
|
||||
onChapterInitiallyScrolled = {
|
||||
if (!isCurrentRenderedChapter()) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
|
||||
)
|
||||
} else {
|
||||
val wasCfiScroll = cfiToLoad != null
|
||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
|
||||
|
|
@ -3720,6 +3729,7 @@ fun EpubReaderHost(
|
|||
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
|
||||
triggerAutoScrollTempPause(1000L)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (isAutoScrollModeActive) {
|
||||
|
|
@ -3886,6 +3896,11 @@ fun EpubReaderHost(
|
|||
},
|
||||
tocFragments = currentChapterTocFragments,
|
||||
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
|
||||
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
|
||||
|
|
@ -3905,6 +3920,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
currentFontSize = currentFontSizeEm,
|
||||
|
|
@ -4059,7 +4075,13 @@ fun EpubReaderHost(
|
|||
}
|
||||
},
|
||||
onWebViewInstanceCreated = { 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,13 +4408,24 @@ fun EpubReaderHost(
|
|||
)
|
||||
},
|
||||
onTopChunkUpdated = { 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 ->
|
||||
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 =
|
||||
|
|
@ -4406,6 +4439,7 @@ fun EpubReaderHost(
|
|||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import android.graphics.Rect
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.pdf.ocr.OcrBlock
|
||||
import com.aryan.reader.pdf.ocr.OcrElement
|
||||
|
|
@ -13,6 +14,7 @@ import com.aryan.reader.pdf.ocr.OcrLine
|
|||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
|
@ -107,6 +109,62 @@ class PdfReaderCoreLogicTest {
|
|||
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf export choice is hidden when loaded sidecars have no annotations`() {
|
||||
assertFalse(
|
||||
shouldShowPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = mapOf(0 to emptyList()),
|
||||
textBoxes = emptyList(),
|
||||
highlights = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf export choice is shown when exportable annotations exist`() {
|
||||
val inkAnnotation = PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = 0,
|
||||
points = listOf(PdfPoint(0.1f, 0.2f)),
|
||||
color = Color.Black,
|
||||
strokeWidth = 0.01f
|
||||
)
|
||||
val textBox = PdfTextBox(
|
||||
id = "box",
|
||||
pageIndex = 0,
|
||||
relativeBounds = androidx.compose.ui.geometry.Rect(0.1f, 0.1f, 0.4f, 0.2f),
|
||||
text = "note",
|
||||
color = Color.Black,
|
||||
backgroundColor = Color.Transparent,
|
||||
fontSize = 16f
|
||||
)
|
||||
val highlight = PdfUserHighlight(
|
||||
pageIndex = 0,
|
||||
bounds = listOf(RectF(0.1f, 0.1f, 0.4f, 0.2f)),
|
||||
color = PdfHighlightColor.YELLOW,
|
||||
text = "selected",
|
||||
range = 0 to 8
|
||||
)
|
||||
|
||||
assertTrue(shouldShowPdfAnnotationExportChoice(true, mapOf(0 to listOf(inkAnnotation)), emptyList(), emptyList()))
|
||||
assertTrue(shouldShowPdfAnnotationExportChoice(true, emptyMap(), listOf(textBox), emptyList()))
|
||||
assertTrue(shouldShowPdfAnnotationExportChoice(true, emptyMap(), emptyList(), listOf(highlight)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf export choice remains available until sidecars are loaded`() {
|
||||
assertTrue(
|
||||
shouldShowPdfAnnotationExportChoice(
|
||||
sidecarsReady = false,
|
||||
annotations = emptyMap(),
|
||||
textBoxes = emptyList(),
|
||||
highlights = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdfRenderPageId separates same page across documents`() {
|
||||
val firstDocumentPage = pdfRenderPageId("book-a", 0, VirtualPage.PdfPage(0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class TtsSpeakerPreferencesTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts speaker preference saves and loads selected ai voice`() {
|
||||
saveTtsSpeaker(context, "Kore")
|
||||
|
||||
assertEquals("Kore", loadTtsSpeaker(context))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts speaker preference falls back to default for blank or unknown voices`() {
|
||||
assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context))
|
||||
|
||||
saveTtsSpeaker(context, "")
|
||||
assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context))
|
||||
|
||||
saveTtsSpeaker(context, "MissingVoice")
|
||||
assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue