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
|
|
@ -99,6 +99,7 @@ import java.io.BufferedReader
|
|||
import java.io.InputStreamReader
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
|
||||
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
||||
"PageInfoReporter",
|
||||
"ProgressReporter",
|
||||
|
|
@ -113,6 +114,68 @@ private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
|
|||
"LinkNavBridge"
|
||||
)
|
||||
|
||||
private class WebViewRuntimeApplierState {
|
||||
var fontCss: String? = null
|
||||
var styleSignature: String? = null
|
||||
var tocFragmentsJson: String? = null
|
||||
var highlightsJson: String? = null
|
||||
private var unchangedUpdateCount = 0
|
||||
private var pendingUpdateCount = 0
|
||||
private var lastUnchangedUpdateLogAt = 0L
|
||||
private var lastPendingUpdateLogAt = 0L
|
||||
|
||||
fun logApplied(
|
||||
chapterTitle: String,
|
||||
fontCssChanged: Boolean,
|
||||
styleChanged: Boolean,
|
||||
tocFragmentsChanged: Boolean,
|
||||
highlightsChanged: Boolean
|
||||
) {
|
||||
if (unchangedUpdateCount > 0) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"androidUpdate resumeAfterUnchanged count=$unchangedUpdateCount chapter='$chapterTitle'"
|
||||
)
|
||||
unchangedUpdateCount = 0
|
||||
}
|
||||
if (pendingUpdateCount > 0) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"androidUpdate resumeAfterPending count=$pendingUpdateCount chapter='$chapterTitle'"
|
||||
)
|
||||
pendingUpdateCount = 0
|
||||
}
|
||||
lastUnchangedUpdateLogAt = System.currentTimeMillis()
|
||||
lastPendingUpdateLogAt = lastUnchangedUpdateLogAt
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"androidUpdate applied chapter='$chapterTitle' fontCss=$fontCssChanged " +
|
||||
"style=$styleChanged toc=$tocFragmentsChanged highlights=$highlightsChanged"
|
||||
)
|
||||
}
|
||||
|
||||
fun logUnchanged(chapterTitle: String) {
|
||||
unchangedUpdateCount++
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastUnchangedUpdateLogAt >= 1000L) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"androidUpdate unchanged count=$unchangedUpdateCount chapter='$chapterTitle'"
|
||||
)
|
||||
unchangedUpdateCount = 0
|
||||
lastUnchangedUpdateLogAt = now
|
||||
}
|
||||
}
|
||||
|
||||
fun logPending(chapterTitle: String) {
|
||||
pendingUpdateCount++
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastPendingUpdateLogAt >= 1000L) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"androidUpdate pendingPageLoad count=$pendingUpdateCount chapter='$chapterTitle'"
|
||||
)
|
||||
pendingUpdateCount = 0
|
||||
lastPendingUpdateLogAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebView.releaseReaderResources() {
|
||||
try {
|
||||
stopLoading()
|
||||
|
|
@ -523,6 +586,9 @@ fun ChapterWebView(
|
|||
currentFontFamily,
|
||||
currentTextAlign
|
||||
) {
|
||||
val runtimeApplierState = remember { WebViewRuntimeApplierState() }
|
||||
var isReaderRuntimeReady by remember { mutableStateOf(false) }
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
Timber.d(
|
||||
|
|
@ -556,6 +622,7 @@ fun ChapterWebView(
|
|||
}).apply {
|
||||
localWebViewRef = this
|
||||
onWebViewInstanceCreated(this)
|
||||
val debugWebViewId = System.identityHashCode(this).toString(16)
|
||||
addJavascriptInterface(
|
||||
PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
|
||||
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
|
||||
|
|
@ -671,6 +738,11 @@ fun ChapterWebView(
|
|||
.d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}")
|
||||
}
|
||||
|
||||
message.startsWith("$TAG_VERTICAL_JITTER:") -> {
|
||||
Timber.tag(TAG_VERTICAL_JITTER)
|
||||
.d("webView=$debugWebViewId chapter='$chapterTitle' JS -> ${message.substringAfter("$TAG_VERTICAL_JITTER: ")}")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d(
|
||||
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
|
||||
|
|
@ -818,11 +890,11 @@ fun ChapterWebView(
|
|||
)
|
||||
|
||||
view?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
|
||||
"javascript:window.CURRENT_HIGHLIGHTS = '${
|
||||
escapeJsString(
|
||||
highlightsJson
|
||||
)
|
||||
}');", null
|
||||
}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null
|
||||
)
|
||||
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
|
|
@ -845,6 +917,20 @@ fun ChapterWebView(
|
|||
currentFontFamily.fontFamilyName
|
||||
}
|
||||
|
||||
runtimeApplierState.fontCss = combinedCss
|
||||
runtimeApplierState.styleSignature = listOf(
|
||||
currentFontSize,
|
||||
currentLineHeight,
|
||||
fontNameForJs,
|
||||
currentTextAlign.cssValue,
|
||||
currentParagraphGap,
|
||||
currentImageSize,
|
||||
currentHorizontalMargin,
|
||||
currentVerticalMargin
|
||||
).joinToString(separator = "|")
|
||||
runtimeApplierState.tocFragmentsJson = fragmentsJson
|
||||
runtimeApplierState.highlightsJson = highlightsJson
|
||||
|
||||
view?.evaluateJavascript(
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||
null
|
||||
|
|
@ -956,6 +1042,9 @@ fun ChapterWebView(
|
|||
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
|
||||
null
|
||||
)
|
||||
isReaderRuntimeReady = true
|
||||
Timber.tag(TAG_VERTICAL_JITTER)
|
||||
.d("androidPageFinished runtimeReady webView=$debugWebViewId chapter='$chapterTitle'")
|
||||
}
|
||||
}
|
||||
settings.apply {
|
||||
|
|
@ -983,6 +1072,7 @@ fun ChapterWebView(
|
|||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRelease = { releasedWebView ->
|
||||
isReaderRuntimeReady = false
|
||||
if (localWebViewRef === releasedWebView) {
|
||||
localWebViewRef = null
|
||||
}
|
||||
|
|
@ -992,42 +1082,84 @@ fun ChapterWebView(
|
|||
releasedWebView.releaseReaderResources()
|
||||
},
|
||||
update = { webView ->
|
||||
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
|
||||
localWebViewRef = webView
|
||||
onWebViewInstanceCreated(webView)
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
val customFontCss = if (customFontPath != null) {
|
||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
||||
} else ""
|
||||
val combinedCss = "$fontCss $customFontCss"
|
||||
val injectFontJs =
|
||||
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
|
||||
webView.evaluateJavascript("javascript:$injectFontJs", null)
|
||||
val fontNameForJs = if (customFontPath != null) {
|
||||
"CustomFont"
|
||||
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
|
||||
""
|
||||
if (!isReaderRuntimeReady) {
|
||||
runtimeApplierState.logPending(chapterTitle)
|
||||
} else {
|
||||
currentFontFamily.fontFamilyName
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
val customFontCss = if (customFontPath != null) {
|
||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
||||
} else ""
|
||||
val combinedCss = "$fontCss $customFontCss"
|
||||
val fontNameForJs = if (customFontPath != null) {
|
||||
"CustomFont"
|
||||
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
|
||||
""
|
||||
} else {
|
||||
currentFontFamily.fontFamilyName
|
||||
}
|
||||
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
||||
val styleSignature = listOf(
|
||||
currentFontSize,
|
||||
currentLineHeight,
|
||||
fontNameForJs,
|
||||
currentTextAlign.cssValue,
|
||||
currentParagraphGap,
|
||||
currentImageSize,
|
||||
currentHorizontalMargin,
|
||||
currentVerticalMargin
|
||||
).joinToString(separator = "|")
|
||||
val fontCssChanged = runtimeApplierState.fontCss != combinedCss
|
||||
val styleChanged = runtimeApplierState.styleSignature != styleSignature
|
||||
val tocFragmentsChanged = runtimeApplierState.tocFragmentsJson != fragmentsJson
|
||||
val highlightsChanged = runtimeApplierState.highlightsJson != highlightsJson
|
||||
|
||||
if (fontCssChanged || styleChanged || tocFragmentsChanged || highlightsChanged) {
|
||||
runtimeApplierState.logApplied(
|
||||
chapterTitle = chapterTitle,
|
||||
fontCssChanged = fontCssChanged,
|
||||
styleChanged = styleChanged,
|
||||
tocFragmentsChanged = tocFragmentsChanged,
|
||||
highlightsChanged = highlightsChanged
|
||||
)
|
||||
} else {
|
||||
runtimeApplierState.logUnchanged(chapterTitle)
|
||||
}
|
||||
|
||||
if (fontCssChanged) {
|
||||
runtimeApplierState.fontCss = combinedCss
|
||||
val injectFontJs =
|
||||
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
|
||||
webView.evaluateJavascript("javascript:$injectFontJs", null)
|
||||
}
|
||||
|
||||
if (tocFragmentsChanged) {
|
||||
runtimeApplierState.tocFragmentsJson = fragmentsJson
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.setTocFragments($fragmentsJson);",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
if (styleChanged) {
|
||||
runtimeApplierState.styleSignature = styleSignature
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
if (highlightsChanged) {
|
||||
runtimeApplierState.highlightsJson = highlightsJson
|
||||
val escapedHighlights = escapeJsString(highlightsJson)
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
|
||||
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.setTocFragments($fragmentsJson);",
|
||||
null
|
||||
)
|
||||
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||
null
|
||||
)
|
||||
|
||||
val escapedHighlights = escapeJsString(highlightsJson)
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
|
||||
null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
|
|||
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
|
||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||
|
||||
|
|
@ -3574,6 +3575,9 @@ fun EpubReaderHost(
|
|||
""".trimIndent()
|
||||
|
||||
val chapterToRender = chapters[targetChapterIndex]
|
||||
fun isCurrentRenderedChapter(): Boolean =
|
||||
targetChapterIndex == currentChapterIndex
|
||||
|
||||
val chapterKeyForWebView =
|
||||
remember(
|
||||
chapterToRender.htmlFilePath,
|
||||
|
|
@ -3629,8 +3633,8 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
val currentChapterTocFragments = remember(epubBook.tableOfContents, currentChapterIndex) {
|
||||
val chapterPath = chapters.getOrNull(currentChapterIndex)?.absPath
|
||||
val currentChapterTocFragments = remember(epubBook.tableOfContents, targetChapterIndex) {
|
||||
val chapterPath = chapters.getOrNull(targetChapterIndex)?.absPath
|
||||
epubBook.tableOfContents
|
||||
.filter { it.absolutePath == chapterPath && it.fragmentId != null }
|
||||
.mapNotNull { it.fragmentId }
|
||||
|
|
@ -3683,42 +3687,48 @@ fun EpubReaderHost(
|
|||
}
|
||||
},
|
||||
onChapterInitiallyScrolled = {
|
||||
val wasCfiScroll = cfiToLoad != null
|
||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
|
||||
initialScrollTargetForChapter = null
|
||||
cfiToLoad = null
|
||||
fragmentToLoad = null
|
||||
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
isWebViewReady = true
|
||||
|
||||
if (wasCfiScroll) {
|
||||
scope.launch {
|
||||
delay(1000L)
|
||||
isChapterReadyForBookmarkCheck = true
|
||||
Timber.d("Auto-save enabled after CFI scroll delay.")
|
||||
}
|
||||
if (!isCurrentRenderedChapter()) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
|
||||
)
|
||||
} else {
|
||||
isChapterReadyForBookmarkCheck = true
|
||||
Timber.d("Auto-save enabled immediately.")
|
||||
}
|
||||
val wasCfiScroll = cfiToLoad != null
|
||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
|
||||
initialScrollTargetForChapter = null
|
||||
cfiToLoad = null
|
||||
fragmentToLoad = null
|
||||
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
isWebViewReady = true
|
||||
|
||||
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
|
||||
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
|
||||
logTtsChapterDiag("Auto-starting TTS extraction for chapter load")
|
||||
hasRequestedExtractionForThisChapter = true
|
||||
scope.launch {
|
||||
delay(200)
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
"javascript:TtsBridgeHelper.extractAndRelayText();",
|
||||
null
|
||||
)
|
||||
if (wasCfiScroll) {
|
||||
scope.launch {
|
||||
delay(1000L)
|
||||
isChapterReadyForBookmarkCheck = true
|
||||
Timber.d("Auto-save enabled after CFI scroll delay.")
|
||||
}
|
||||
} else {
|
||||
isChapterReadyForBookmarkCheck = true
|
||||
Timber.d("Auto-save enabled immediately.")
|
||||
}
|
||||
}
|
||||
|
||||
if (isAutoScrollModeActive && isAutoScrollPlaying) {
|
||||
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
|
||||
triggerAutoScrollTempPause(1000L)
|
||||
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
|
||||
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
|
||||
logTtsChapterDiag("Auto-starting TTS extraction for chapter load")
|
||||
hasRequestedExtractionForThisChapter = true
|
||||
scope.launch {
|
||||
delay(200)
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
"javascript:TtsBridgeHelper.extractAndRelayText();",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAutoScrollModeActive && isAutoScrollPlaying) {
|
||||
Timber.d("Continuing Auto-Scroll for new chapter with delay.")
|
||||
triggerAutoScrollTempPause(1000L)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
|
|
@ -3886,22 +3896,28 @@ fun EpubReaderHost(
|
|||
},
|
||||
tocFragments = currentChapterTocFragments,
|
||||
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
|
||||
currentScrollYPosition = scrollY
|
||||
currentScrollHeightValue = scrollHeight
|
||||
currentClientHeightValue = clientHeight
|
||||
if (!isCurrentRenderedChapter()) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale scrollState rendered=$targetChapterIndex current=$currentChapterIndex y=$scrollY height=$scrollHeight chapter='${chapterToRender.title}'"
|
||||
)
|
||||
} else {
|
||||
currentScrollYPosition = scrollY
|
||||
currentScrollHeightValue = scrollHeight
|
||||
currentClientHeightValue = clientHeight
|
||||
|
||||
if (activeFragmentId != fragId) {
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
|
||||
activeFragmentId = fragId
|
||||
}
|
||||
if (activeFragmentId != fragId) {
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId")
|
||||
activeFragmentId = fragId
|
||||
}
|
||||
|
||||
if (volumeScrollEnabled && !searchState.isSearchActive) {
|
||||
volumeScrollFocusDebounceJob.value?.cancel()
|
||||
volumeScrollFocusDebounceJob.value = scope.launch {
|
||||
delay(300L)
|
||||
if (isActive) {
|
||||
containerFocusRequester.requestFocus()
|
||||
Timber.d("Refocusing container after scroll to re-enable volume keys.")
|
||||
if (volumeScrollEnabled && !searchState.isSearchActive) {
|
||||
volumeScrollFocusDebounceJob.value?.cancel()
|
||||
volumeScrollFocusDebounceJob.value = scope.launch {
|
||||
delay(300L)
|
||||
if (isActive) {
|
||||
containerFocusRequester.requestFocus()
|
||||
Timber.d("Refocusing container after scroll to re-enable volume keys.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4059,7 +4075,13 @@ fun EpubReaderHost(
|
|||
}
|
||||
},
|
||||
onWebViewInstanceCreated = { webView ->
|
||||
webViewRefForTts = webView
|
||||
if (isCurrentRenderedChapter()) {
|
||||
webViewRefForTts = webView
|
||||
} else {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale webViewRef rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'"
|
||||
)
|
||||
}
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.setViewportPadding(${topPaddingPx}, 0);",
|
||||
null
|
||||
|
|
@ -4386,25 +4408,37 @@ fun EpubReaderHost(
|
|||
)
|
||||
},
|
||||
onTopChunkUpdated = { chunkIndex ->
|
||||
topVisibleChunkIndex = chunkIndex
|
||||
if (isCurrentRenderedChapter()) {
|
||||
topVisibleChunkIndex = chunkIndex
|
||||
} else {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale topChunk rendered=$targetChapterIndex current=$currentChapterIndex chunk=$chunkIndex chapter='${chapterToRender.title}'"
|
||||
)
|
||||
}
|
||||
},
|
||||
initialHtmlContent = initialHtml,
|
||||
baseUrl = baseUrl,
|
||||
totalChunks = chapterChunks.size,
|
||||
initialChunkIndex = loadUpToChunkIndex,
|
||||
onChunkRequested = { index ->
|
||||
val chunkContent = chapterChunks.getOrNull(index)
|
||||
if (chunkContent != null) {
|
||||
loadedChunkCount =
|
||||
max(loadedChunkCount, index + 1)
|
||||
val escapedContent =
|
||||
escapeJsString(chunkContent)
|
||||
val jsCommand =
|
||||
"javascript:window.virtualization.appendChunk($index, '$escapedContent');"
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
jsCommand,
|
||||
null
|
||||
if (!isCurrentRenderedChapter()) {
|
||||
Timber.tag(TAG_VERTICAL_JITTER).d(
|
||||
"ignored stale chunkRequest rendered=$targetChapterIndex current=$currentChapterIndex chunk=$index chapter='${chapterToRender.title}'"
|
||||
)
|
||||
} else {
|
||||
val chunkContent = chapterChunks.getOrNull(index)
|
||||
if (chunkContent != null) {
|
||||
loadedChunkCount =
|
||||
max(loadedChunkCount, index + 1)
|
||||
val escapedContent =
|
||||
escapeJsString(chunkContent)
|
||||
val jsCommand =
|
||||
"javascript:window.virtualization.appendChunk($index, '$escapedContent');"
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
jsCommand,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue