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"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 51
|
versionCode = 52
|
||||||
versionName = "1.0.47"
|
versionName = "1.0.48"
|
||||||
|
|
||||||
resourceConfigurations += configuredAppLocaleTags()
|
resourceConfigurations += configuredAppLocaleTags()
|
||||||
.map { it.toAndroidResourceConfiguration() }
|
.map { it.toAndroidResourceConfiguration() }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
// epub_reader.js
|
// epub_reader.js
|
||||||
(function () {
|
(function () {
|
||||||
|
var READER_VERTICAL_JITTER_TAG = "EpubVerticalJitter";
|
||||||
|
function logVerticalJitter(message) {
|
||||||
|
console.log(READER_VERTICAL_JITTER_TAG + ": " + message);
|
||||||
|
}
|
||||||
|
window.logReaderVerticalJitter = window.logReaderVerticalJitter || logVerticalJitter;
|
||||||
|
|
||||||
function applyMobileOptimizationsAndSelection() {
|
function applyMobileOptimizationsAndSelection() {
|
||||||
var viewport = document.querySelector("meta[name=viewport]");
|
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(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
|
||||||
if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 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();
|
rememberReaderImageAnchors();
|
||||||
|
|
||||||
var fontCss = "";
|
var fontCss = "";
|
||||||
|
|
@ -964,6 +994,16 @@
|
||||||
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
|
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
|
||||||
applyReaderImageAnchors();
|
applyReaderImageAnchors();
|
||||||
setTimeout(applyReaderImageAnchors, 80);
|
setTimeout(applyReaderImageAnchors, 80);
|
||||||
|
logVerticalJitter(
|
||||||
|
"jsStyleApply scrollY=" +
|
||||||
|
scrollYBeforeStyle +
|
||||||
|
" scrollHeight=" +
|
||||||
|
scrollHeightBeforeStyle +
|
||||||
|
"->" +
|
||||||
|
Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)) +
|
||||||
|
" signature=" +
|
||||||
|
styleSignature,
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
function () {
|
function () {
|
||||||
|
|
@ -992,18 +1032,105 @@
|
||||||
} else if (window.reportScrollState) {
|
} else if (window.reportScrollState) {
|
||||||
setTimeout(window.reportScrollState, 60);
|
setTimeout(window.reportScrollState, 60);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.TOC_FRAGMENTS = window.TOC_FRAGMENTS || [];
|
window.TOC_FRAGMENTS = window.TOC_FRAGMENTS || [];
|
||||||
|
|
||||||
window.setTocFragments = function (jsonArray) {
|
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.");
|
console.log("FRAG_NAV_DEBUG: window.setTocFragments called with " + jsonArray.length + " items.");
|
||||||
|
logVerticalJitter("tocApply count=" + jsonArray.length);
|
||||||
window.TOC_FRAGMENTS = jsonArray;
|
window.TOC_FRAGMENTS = jsonArray;
|
||||||
// Immediate audit and report
|
// Immediate audit and report
|
||||||
window.auditTocFragments();
|
window.auditTocFragments();
|
||||||
window.reportScrollState();
|
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 () {
|
window.reportScrollState = function () {
|
||||||
if (typeof PageInfoReporter !== "undefined" && PageInfoReporter.updateScrollState) {
|
if (typeof PageInfoReporter !== "undefined" && PageInfoReporter.updateScrollState) {
|
||||||
var scrollY = Math.round(window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0);
|
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);
|
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();
|
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 scrollThrottleTimeout = null;
|
||||||
let lastScrollTime = 0;
|
let lastScrollTime = 0;
|
||||||
|
|
||||||
|
|
@ -2364,6 +2553,11 @@
|
||||||
(function () {
|
(function () {
|
||||||
// --- VIRTUALIZATION LOGIC ---
|
// --- VIRTUALIZATION LOGIC ---
|
||||||
if (window.virtualization) return;
|
if (window.virtualization) return;
|
||||||
|
const logVerticalJitter =
|
||||||
|
window.logReaderVerticalJitter ||
|
||||||
|
function (message) {
|
||||||
|
console.log("EpubVerticalJitter: " + message);
|
||||||
|
};
|
||||||
|
|
||||||
let currentBottomChunkIndex = 0;
|
let currentBottomChunkIndex = 0;
|
||||||
let totalChunks = 0;
|
let totalChunks = 0;
|
||||||
|
|
@ -2378,6 +2572,16 @@
|
||||||
|
|
||||||
init: function (initialChunkIndex, total) {
|
init: function (initialChunkIndex, total) {
|
||||||
console.log(`Virtualization: Init with ${total} chunks. Anchor: ${initialChunkIndex}`);
|
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.totalChunks = total;
|
||||||
this.chunksData = new Array(total).fill(null);
|
this.chunksData = new Array(total).fill(null);
|
||||||
this.chunkHeights = new Array(total).fill(0);
|
this.chunkHeights = new Array(total).fill(0);
|
||||||
|
|
@ -2413,6 +2617,7 @@
|
||||||
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
if (!this.chunksData[idx]) {
|
if (!this.chunksData[idx]) {
|
||||||
|
logVerticalJitter("virtRequestChunk idx=" + idx);
|
||||||
if (window.ContentBridge && window.ContentBridge.requestChunk) {
|
if (window.ContentBridge && window.ContentBridge.requestChunk) {
|
||||||
window.ContentBridge.requestChunk(idx);
|
window.ContentBridge.requestChunk(idx);
|
||||||
}
|
}
|
||||||
|
|
@ -2428,6 +2633,18 @@
|
||||||
if (div.getBoundingClientRect().top < 0) {
|
if (div.getBoundingClientRect().top < 0) {
|
||||||
scrollAdjust += (newHeight - oldHeight);
|
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) {
|
if (window.CURRENT_HIGHLIGHTS) {
|
||||||
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
||||||
}
|
}
|
||||||
|
|
@ -2442,12 +2659,29 @@
|
||||||
div.style.height = oldHeight + "px";
|
div.style.height = oldHeight + "px";
|
||||||
div.innerHTML = "";
|
div.innerHTML = "";
|
||||||
domChanged = true;
|
domChanged = true;
|
||||||
|
logVerticalJitter(
|
||||||
|
"virtUnload idx=" +
|
||||||
|
idx +
|
||||||
|
" placeholderHeight=" +
|
||||||
|
Math.round(oldHeight) +
|
||||||
|
" top=" +
|
||||||
|
Math.round(div.getBoundingClientRect().top),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (scrollAdjust !== 0) {
|
if (scrollAdjust !== 0) {
|
||||||
|
let before = Math.round(window.scrollY || window.pageYOffset || 0);
|
||||||
window.scrollBy(0, scrollAdjust);
|
window.scrollBy(0, scrollAdjust);
|
||||||
|
logVerticalJitter(
|
||||||
|
"virtScrollAdjust dy=" +
|
||||||
|
Math.round(scrollAdjust) +
|
||||||
|
" scrollY=" +
|
||||||
|
before +
|
||||||
|
"->" +
|
||||||
|
Math.round(window.scrollY || window.pageYOffset || 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domChanged && window.reportScrollState) {
|
if (domChanged && window.reportScrollState) {
|
||||||
|
|
@ -2464,6 +2698,14 @@
|
||||||
|
|
||||||
appendChunk: function (index, htmlContent) {
|
appendChunk: function (index, htmlContent) {
|
||||||
console.log(`Virtualization: Receiving chunk ${index} from Kotlin`);
|
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)) {
|
if (Array.isArray(this.chunksData)) {
|
||||||
this.chunksData[index] = htmlContent;
|
this.chunksData[index] = htmlContent;
|
||||||
|
|
@ -2482,7 +2724,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (div.getBoundingClientRect().bottom < 0) {
|
if (div.getBoundingClientRect().bottom < 0) {
|
||||||
|
let before = Math.round(window.scrollY || window.pageYOffset || 0);
|
||||||
window.scrollBy(0, newHeight - oldHeight);
|
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) {
|
if (window.reportScrollState) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
|
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
|
||||||
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||||
"PageInfoReporter",
|
"PageInfoReporter",
|
||||||
"ProgressReporter",
|
"ProgressReporter",
|
||||||
|
|
@ -113,6 +114,68 @@ private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||||
"LinkNavBridge"
|
"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() {
|
private fun WebView.releaseReaderResources() {
|
||||||
try {
|
try {
|
||||||
stopLoading()
|
stopLoading()
|
||||||
|
|
@ -523,6 +586,9 @@ fun ChapterWebView(
|
||||||
currentFontFamily,
|
currentFontFamily,
|
||||||
currentTextAlign
|
currentTextAlign
|
||||||
) {
|
) {
|
||||||
|
val runtimeApplierState = remember { WebViewRuntimeApplierState() }
|
||||||
|
var isReaderRuntimeReady by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
Timber.d(
|
Timber.d(
|
||||||
|
|
@ -556,6 +622,7 @@ fun ChapterWebView(
|
||||||
}).apply {
|
}).apply {
|
||||||
localWebViewRef = this
|
localWebViewRef = this
|
||||||
onWebViewInstanceCreated(this)
|
onWebViewInstanceCreated(this)
|
||||||
|
val debugWebViewId = System.identityHashCode(this).toString(16)
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
|
PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
|
||||||
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
|
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
|
||||||
|
|
@ -671,6 +738,11 @@ fun ChapterWebView(
|
||||||
.d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}")
|
.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 -> {
|
else -> {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
|
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
|
||||||
|
|
@ -818,11 +890,11 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
|
|
||||||
view?.evaluateJavascript(
|
view?.evaluateJavascript(
|
||||||
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
|
"javascript:window.CURRENT_HIGHLIGHTS = '${
|
||||||
escapeJsString(
|
escapeJsString(
|
||||||
highlightsJson
|
highlightsJson
|
||||||
)
|
)
|
||||||
}');", null
|
}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null
|
||||||
)
|
)
|
||||||
|
|
||||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||||
|
|
@ -845,6 +917,20 @@ fun ChapterWebView(
|
||||||
currentFontFamily.fontFamilyName
|
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(
|
view?.evaluateJavascript(
|
||||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||||
null
|
null
|
||||||
|
|
@ -956,6 +1042,9 @@ fun ChapterWebView(
|
||||||
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
isReaderRuntimeReady = true
|
||||||
|
Timber.tag(TAG_VERTICAL_JITTER)
|
||||||
|
.d("androidPageFinished runtimeReady webView=$debugWebViewId chapter='$chapterTitle'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings.apply {
|
settings.apply {
|
||||||
|
|
@ -983,6 +1072,7 @@ fun ChapterWebView(
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onRelease = { releasedWebView ->
|
onRelease = { releasedWebView ->
|
||||||
|
isReaderRuntimeReady = false
|
||||||
if (localWebViewRef === releasedWebView) {
|
if (localWebViewRef === releasedWebView) {
|
||||||
localWebViewRef = null
|
localWebViewRef = null
|
||||||
}
|
}
|
||||||
|
|
@ -992,42 +1082,84 @@ fun ChapterWebView(
|
||||||
releasedWebView.releaseReaderResources()
|
releasedWebView.releaseReaderResources()
|
||||||
},
|
},
|
||||||
update = { webView ->
|
update = { webView ->
|
||||||
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
|
|
||||||
localWebViewRef = webView
|
localWebViewRef = webView
|
||||||
onWebViewInstanceCreated(webView)
|
onWebViewInstanceCreated(webView)
|
||||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
if (!isReaderRuntimeReady) {
|
||||||
val customFontCss = if (customFontPath != null) {
|
runtimeApplierState.logPending(chapterTitle)
|
||||||
"@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) {
|
|
||||||
""
|
|
||||||
} else {
|
} 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 TTS_LOCATE_REASON_OVERLAY = "overlay"
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
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_STABLE_PAGE_NAV = "StablePageNav"
|
||||||
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||||
|
|
||||||
|
|
@ -3574,6 +3575,9 @@ fun EpubReaderHost(
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val chapterToRender = chapters[targetChapterIndex]
|
val chapterToRender = chapters[targetChapterIndex]
|
||||||
|
fun isCurrentRenderedChapter(): Boolean =
|
||||||
|
targetChapterIndex == currentChapterIndex
|
||||||
|
|
||||||
val chapterKeyForWebView =
|
val chapterKeyForWebView =
|
||||||
remember(
|
remember(
|
||||||
chapterToRender.htmlFilePath,
|
chapterToRender.htmlFilePath,
|
||||||
|
|
@ -3629,8 +3633,8 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentChapterTocFragments = remember(epubBook.tableOfContents, currentChapterIndex) {
|
val currentChapterTocFragments = remember(epubBook.tableOfContents, targetChapterIndex) {
|
||||||
val chapterPath = chapters.getOrNull(currentChapterIndex)?.absPath
|
val chapterPath = chapters.getOrNull(targetChapterIndex)?.absPath
|
||||||
epubBook.tableOfContents
|
epubBook.tableOfContents
|
||||||
.filter { it.absolutePath == chapterPath && it.fragmentId != null }
|
.filter { it.absolutePath == chapterPath && it.fragmentId != null }
|
||||||
.mapNotNull { it.fragmentId }
|
.mapNotNull { it.fragmentId }
|
||||||
|
|
@ -3683,42 +3687,48 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChapterInitiallyScrolled = {
|
onChapterInitiallyScrolled = {
|
||||||
val wasCfiScroll = cfiToLoad != null
|
if (!isCurrentRenderedChapter()) {
|
||||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
|
"ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
|
||||||
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.")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isChapterReadyForBookmarkCheck = true
|
val wasCfiScroll = cfiToLoad != null
|
||||||
Timber.d("Auto-save enabled immediately.")
|
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) {
|
if (wasCfiScroll) {
|
||||||
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
|
scope.launch {
|
||||||
logTtsChapterDiag("Auto-starting TTS extraction for chapter load")
|
delay(1000L)
|
||||||
hasRequestedExtractionForThisChapter = true
|
isChapterReadyForBookmarkCheck = true
|
||||||
scope.launch {
|
Timber.d("Auto-save enabled after CFI scroll delay.")
|
||||||
delay(200)
|
}
|
||||||
webViewRefForTts?.evaluateJavascript(
|
} else {
|
||||||
"javascript:TtsBridgeHelper.extractAndRelayText();",
|
isChapterReadyForBookmarkCheck = true
|
||||||
null
|
Timber.d("Auto-save enabled immediately.")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isAutoScrollModeActive && isAutoScrollPlaying) {
|
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
|
||||||
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
|
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
|
||||||
triggerAutoScrollTempPause(1000L)
|
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 = {
|
onTap = {
|
||||||
|
|
@ -3886,22 +3896,28 @@ fun EpubReaderHost(
|
||||||
},
|
},
|
||||||
tocFragments = currentChapterTocFragments,
|
tocFragments = currentChapterTocFragments,
|
||||||
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
|
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
|
||||||
currentScrollYPosition = scrollY
|
if (!isCurrentRenderedChapter()) {
|
||||||
currentScrollHeightValue = scrollHeight
|
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||||
currentClientHeightValue = clientHeight
|
"ignored stale scrollState rendered=$targetChapterIndex current=$currentChapterIndex y=$scrollY height=$scrollHeight chapter='${chapterToRender.title}'"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
currentScrollYPosition = scrollY
|
||||||
|
currentScrollHeightValue = scrollHeight
|
||||||
|
currentClientHeightValue = clientHeight
|
||||||
|
|
||||||
if (activeFragmentId != fragId) {
|
if (activeFragmentId != fragId) {
|
||||||
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
|
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
|
||||||
activeFragmentId = fragId
|
activeFragmentId = fragId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (volumeScrollEnabled && !searchState.isSearchActive) {
|
if (volumeScrollEnabled && !searchState.isSearchActive) {
|
||||||
volumeScrollFocusDebounceJob.value?.cancel()
|
volumeScrollFocusDebounceJob.value?.cancel()
|
||||||
volumeScrollFocusDebounceJob.value = scope.launch {
|
volumeScrollFocusDebounceJob.value = scope.launch {
|
||||||
delay(300L)
|
delay(300L)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
containerFocusRequester.requestFocus()
|
containerFocusRequester.requestFocus()
|
||||||
Timber.d("Refocusing container after scroll to re-enable volume keys.")
|
Timber.d("Refocusing container after scroll to re-enable volume keys.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4059,7 +4075,13 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onWebViewInstanceCreated = { webView ->
|
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(
|
webView.evaluateJavascript(
|
||||||
"javascript:window.setViewportPadding(${topPaddingPx}, 0);",
|
"javascript:window.setViewportPadding(${topPaddingPx}, 0);",
|
||||||
null
|
null
|
||||||
|
|
@ -4386,25 +4408,37 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTopChunkUpdated = { chunkIndex ->
|
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,
|
initialHtmlContent = initialHtml,
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
totalChunks = chapterChunks.size,
|
totalChunks = chapterChunks.size,
|
||||||
initialChunkIndex = loadUpToChunkIndex,
|
initialChunkIndex = loadUpToChunkIndex,
|
||||||
onChunkRequested = { index ->
|
onChunkRequested = { index ->
|
||||||
val chunkContent = chapterChunks.getOrNull(index)
|
if (!isCurrentRenderedChapter()) {
|
||||||
if (chunkContent != null) {
|
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||||
loadedChunkCount =
|
"ignored stale chunkRequest rendered=$targetChapterIndex current=$currentChapterIndex chunk=$index chapter='${chapterToRender.title}'"
|
||||||
max(loadedChunkCount, index + 1)
|
|
||||||
val escapedContent =
|
|
||||||
escapeJsString(chunkContent)
|
|
||||||
val jsCommand =
|
|
||||||
"javascript:window.virtualization.appendChunk($index, '$escapedContent');"
|
|
||||||
webViewRefForTts?.evaluateJavascript(
|
|
||||||
jsCommand,
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import android.print.PrintDocumentInfo
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.core.graphics.createBitmap
|
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 io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -135,6 +137,25 @@ internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean):
|
||||||
return "${safeBase}${suffix}_${shortId}.pdf"
|
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 {
|
internal fun getFastFileId(context: Context, uri: Uri): String {
|
||||||
var result = uri.toString()
|
var result = uri.toString()
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2256,6 +2256,91 @@ fun PdfViewerScreen(
|
||||||
var showShareDialog by remember { mutableStateOf(false) }
|
var showShareDialog by remember { mutableStateOf(false) }
|
||||||
var showSaveDialog by remember { mutableStateOf(false) }
|
var showSaveDialog by remember { mutableStateOf(false) }
|
||||||
var isShareLoading 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) }
|
var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -5358,8 +5443,8 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare = { showShareDialog = true },
|
onShare = requestShare,
|
||||||
onSaveCopy = { showSaveDialog = true },
|
onSaveCopy = requestSaveCopy,
|
||||||
onPrint = onPrintDocument,
|
onPrint = onPrintDocument,
|
||||||
onTabClick = { tabBookId ->
|
onTabClick = { tabBookId ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -7030,11 +7115,7 @@ fun PdfViewerScreen(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showSaveDialog = false
|
showSaveDialog = false
|
||||||
pendingSaveMode = SaveMode.ANNOTATED
|
launchAnnotatedSaveCopy()
|
||||||
val suggestedName = getSuggestedFilename(
|
|
||||||
originalFileName, isAnnotated = true
|
|
||||||
)
|
|
||||||
saveLauncher.launch(suggestedName)
|
|
||||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -7044,11 +7125,7 @@ fun PdfViewerScreen(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showSaveDialog = false
|
showSaveDialog = false
|
||||||
pendingSaveMode = SaveMode.ORIGINAL
|
launchOriginalSaveCopy()
|
||||||
val suggestedName = getSuggestedFilename(
|
|
||||||
originalFileName, isAnnotated = false
|
|
||||||
)
|
|
||||||
saveLauncher.launch(suggestedName)
|
|
||||||
}) { Text(stringResource(R.string.action_original)) }
|
}) { Text(stringResource(R.string.action_original)) }
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
@ -7072,27 +7149,7 @@ fun PdfViewerScreen(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showShareDialog = false
|
showShareDialog = false
|
||||||
isShareLoading = true
|
shareAnnotatedPdf()
|
||||||
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
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -7102,20 +7159,7 @@ fun PdfViewerScreen(
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showShareDialog = false
|
showShareDialog = false
|
||||||
isShareLoading = true
|
shareOriginalPdf()
|
||||||
val filename = getSuggestedFilename(
|
|
||||||
originalFileName, isAnnotated = false
|
|
||||||
)
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.sharePdf(
|
|
||||||
activityContext = context,
|
|
||||||
sourceUri = pdfUri,
|
|
||||||
annotations = emptyMap(),
|
|
||||||
includeAnnotations = false,
|
|
||||||
filename = filename
|
|
||||||
)
|
|
||||||
isShareLoading = false
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.action_original)) }
|
}) { Text(stringResource(R.string.action_original)) }
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
TextButton(onClick = { showShareDialog = false }) {
|
TextButton(onClick = { showShareDialog = false }) {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
|
|
@ -54,19 +52,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
@ -101,7 +86,7 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val initialSpeakerId = loadSpeaker(this.context)
|
val initialSpeakerId = loadTtsSpeaker(this.context)
|
||||||
_ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId)
|
_ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,11 +221,12 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun changeSpeaker(speakerId: String) {
|
fun changeSpeaker(speakerId: String) {
|
||||||
Timber.d("UI sending CHANGE_SPEAKER command.")
|
Timber.d("UI sending CHANGE_SPEAKER command.")
|
||||||
saveSpeaker(context, speakerId)
|
val safeSpeakerId = normalizeTtsSpeakerId(speakerId)
|
||||||
_ttsState.value = _ttsState.value.copy(speakerId = speakerId)
|
saveTtsSpeaker(context, safeSpeakerId)
|
||||||
|
_ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId)
|
||||||
|
|
||||||
val args = Bundle().apply {
|
val args = Bundle().apply {
|
||||||
putString(KEY_SPEAKER_ID, speakerId)
|
putString(KEY_SPEAKER_ID, safeSpeakerId)
|
||||||
}
|
}
|
||||||
mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args)
|
mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,15 +130,22 @@ class TtsPlaybackManager(
|
||||||
val ttsMode: String = TtsMode.CLOUD.name
|
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 var textChunks: List<TtsChunk> = emptyList()
|
||||||
private val audioFiles = java.util.concurrent.ConcurrentHashMap<Int, File>()
|
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 bookTitle: String? = null
|
||||||
private var chapterTitle: String? = null
|
private var chapterTitle: String? = null
|
||||||
private var coverImageUri: String? = null
|
private var coverImageUri: String? = null
|
||||||
private var currentTtsMode = TtsMode.CLOUD
|
private var currentTtsMode = initialTtsMode
|
||||||
private var chapterIndex: Int? = null
|
private var chapterIndex: Int? = null
|
||||||
private var totalChapters: Int? = null
|
private var totalChapters: Int? = null
|
||||||
|
|
||||||
|
|
@ -157,6 +164,12 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
fun setMediaSession(session: MediaSession) {
|
fun setMediaSession(session: MediaSession) {
|
||||||
this.mediaSession = session
|
this.mediaSession = session
|
||||||
|
session.setCustomLayout(
|
||||||
|
listOf(
|
||||||
|
createStateButton(_ttsState.value),
|
||||||
|
createStopCommandButton()
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnect(
|
override fun onConnect(
|
||||||
|
|
@ -422,8 +435,10 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
onPlaybackSessionPreparing(bookTitle, chapterTitle)
|
onPlaybackSessionPreparing(bookTitle, chapterTitle)
|
||||||
|
|
||||||
|
val effectiveSpeakerId = normalizeTtsSpeakerId(speakerId)
|
||||||
|
|
||||||
textChunks = chunks
|
textChunks = chunks
|
||||||
currentSpeakerId = speakerId
|
currentSpeakerId = effectiveSpeakerId
|
||||||
currentTtsMode = ttsMode
|
currentTtsMode = ttsMode
|
||||||
this.bookTitle = bookTitle
|
this.bookTitle = bookTitle
|
||||||
this.chapterTitle = chapterTitle
|
this.chapterTitle = chapterTitle
|
||||||
|
|
@ -443,7 +458,7 @@ class TtsPlaybackManager(
|
||||||
currentChunkIndex = -1,
|
currentChunkIndex = -1,
|
||||||
totalChunks = chunks.size,
|
totalChunks = chunks.size,
|
||||||
bookProgressPercent = calculateBookProgressPercent(-1),
|
bookProgressPercent = calculateBookProgressPercent(-1),
|
||||||
speakerId = speakerId,
|
speakerId = effectiveSpeakerId,
|
||||||
playbackSource = playbackSource,
|
playbackSource = playbackSource,
|
||||||
ttsMode = ttsMode.name,
|
ttsMode = ttsMode.name,
|
||||||
currentText = if (continueSession) _ttsState.value.currentText else null
|
currentText = if (continueSession) _ttsState.value.currentText else null
|
||||||
|
|
@ -480,10 +495,12 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleChangeSpeaker(newSpeakerId: String) {
|
private fun handleChangeSpeaker(newSpeakerId: String) {
|
||||||
if (currentSpeakerId == newSpeakerId) return
|
val safeSpeakerId = normalizeTtsSpeakerId(newSpeakerId)
|
||||||
currentSpeakerId = newSpeakerId
|
if (currentSpeakerId == safeSpeakerId) return
|
||||||
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
|
currentSpeakerId = safeSpeakerId
|
||||||
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
|
saveTtsSpeaker(appContext, safeSpeakerId)
|
||||||
|
_ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId)
|
||||||
|
Timber.d("Speaker changed to $safeSpeakerId (pending next start)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun currentChunkIndexFromPlayer(): Int {
|
private fun currentChunkIndexFromPlayer(): Int {
|
||||||
|
|
@ -681,7 +698,11 @@ class TtsPlaybackManager(
|
||||||
preparationJob?.cancel()
|
preparationJob?.cancel()
|
||||||
wordTrackingJob?.cancel()
|
wordTrackingJob?.cancel()
|
||||||
if (clearState) {
|
if (clearState) {
|
||||||
val finalState = TtsState(sessionEndedByStop = userInitiated)
|
val finalState = TtsState(
|
||||||
|
sessionEndedByStop = userInitiated,
|
||||||
|
speakerId = currentSpeakerId,
|
||||||
|
ttsMode = currentTtsMode.name
|
||||||
|
)
|
||||||
_ttsState.value = finalState
|
_ttsState.value = finalState
|
||||||
mediaSession?.let { session ->
|
mediaSession?.let { session ->
|
||||||
val layout = listOf(
|
val layout = listOf(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.annotation.OptIn
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -42,6 +43,8 @@ const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL
|
||||||
|
|
||||||
const val TTS_CHUNK_MAX_LENGTH = 250
|
const val TTS_CHUNK_MAX_LENGTH = 250
|
||||||
const val DEFAULT_SPEAKER_ID = "Aoede"
|
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)
|
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"),
|
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(
|
data class TtsChapterCacheInfo(
|
||||||
val chapterTitle: String,
|
val chapterTitle: String,
|
||||||
val chunkCount: Int,
|
val chunkCount: Int,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.graphics.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
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.data.VirtualPage
|
||||||
import com.aryan.reader.pdf.ocr.OcrBlock
|
import com.aryan.reader.pdf.ocr.OcrBlock
|
||||||
import com.aryan.reader.pdf.ocr.OcrElement
|
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 com.aryan.reader.pdf.ocr.OcrResult
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -107,6 +109,62 @@ class PdfReaderCoreLogicTest {
|
||||||
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
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
|
@Test
|
||||||
fun `pdfRenderPageId separates same page across documents`() {
|
fun `pdfRenderPageId separates same page across documents`() {
|
||||||
val firstDocumentPage = pdfRenderPageId("book-a", 0, VirtualPage.PdfPage(0))
|
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