App themes (#217)

* Updated `.gitattributes` to correctly vendor all files within the `libmobi` and `woff2` directories.

* Improved TTS chapter transition handling

* Improved TTS stability and chapter advancement in the EPUB reader.

* Implemented PDF metadata extraction for titles and authors using PdfiumCore in `MainViewModel` and `MetadataExtractionWorker`.

* Implemented support for interactive footnotes in the EPUB vertical reader.

* Added support for footnotes in the paginated EPUB reader.

* Implemented a customizable app theming system.

* Added support for app-wide contrast and text brightness customization in the "App Theme" option.

* option to adjust pull-to-chapter-change drag in the EPUB vertical reader under visual options.

* Updated `PdfViewerScreen` to replace the standalone full-screen toggle with a more comprehensive "Visual Options" system UI management tool.
This commit is contained in:
Aryan 2026-04-21 18:11:55 +05:30 committed by GitHub
parent 4cb25e6abd
commit 71e3614ad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1413 additions and 287 deletions

View file

@ -126,20 +126,21 @@ class AutoScrollJsBridge(
}
}
@Suppress("unused") // function used by JavaScript
@Suppress("unused")
class TtsJsBridge(
private val scope: CoroutineScope,
private val ttsStructuredTextHandler: suspend (String) -> Unit
) {
@JavascriptInterface
fun onStructuredTextExtracted(json: String) {
Timber.tag("TTS_LIST_DIAG").d("Bridge received JSON: $json")
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS Bridge received JSON. Length: ${json.length}")
if (json.isNotBlank() && json != "[]") {
scope.launch {
scope.launch(kotlinx.coroutines.Dispatchers.Default) {
ttsStructuredTextHandler(json)
}
} else {
scope.launch {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").w("JS Bridge received empty or blank JSON. This may trigger a chapter skip.")
scope.launch(kotlinx.coroutines.Dispatchers.Default) {
ttsStructuredTextHandler("[]")
}
}
@ -301,6 +302,17 @@ class AiJsBridge(
}
}
@Suppress("unused")
class FootnoteJsBridge(
private val onFootnoteRequestCallback: (String) -> Unit
) {
@JavascriptInterface
fun onFootnoteRequested(htmlContent: String) {
Timber.tag("FootnoteDiag").d("Kotlin Bridge received footnote content. Length: ${htmlContent.length}")
onFootnoteRequestCallback(htmlContent)
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ChapterWebView(
@ -350,6 +362,7 @@ fun ChapterWebView(
onSearch: (String) -> Unit,
onNoteRequested: (String?) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
currentTextAlign: ReaderTextAlign,
@ -537,6 +550,15 @@ fun ChapterWebView(
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("FootnoteDiag:") -> {
Timber.tag("FootnoteDiag")
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
}
message.startsWith("TTS_CHAPTER_CHANGE_DIAG:") -> {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS -> ${message.substringAfter("TTS_CHAPTER_CHANGE_DIAG: ")}")
}
message.startsWith("BookmarkDiagnosis") -> {
Timber.tag("BookmarkDiagnosis")
.d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
@ -625,6 +647,12 @@ fun ChapterWebView(
AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge"
)
addJavascriptInterface(
FootnoteJsBridge { html ->
this.post { onFootnoteRequested(html) }
}, "FootnoteBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?

View file

@ -20,6 +20,7 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.widget.TextView
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -54,15 +55,19 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
@ -905,4 +910,87 @@ fun PaginatedTextSelectionMenu(
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FootnoteBottomSheet(
htmlContent: String,
effectiveBg: Color,
effectiveText: Color,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val maxSheetHeight = configuration.screenHeightDp.dp * 0.5f
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = effectiveBg,
contentColor = effectiveText,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.label_note),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Surface(
color = effectiveText.copy(alpha = 0.05f),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, effectiveText.copy(alpha = 0.1f)),
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
AndroidView(
factory = { context ->
TextView(context).apply {
setTextColor(effectiveText.toArgb())
textSize = 16f
setLineSpacing(0f, 1.4f)
isVerticalScrollBarEnabled = false
movementMethod = null
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(
htmlContent,
HtmlCompat.FROM_HTML_MODE_COMPACT
).trimEnd()
}
)
}
}
}
}
}

View file

@ -62,6 +62,7 @@ suspend fun loadChapterContent(
val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
doc.select("script").remove()
val bodyNodes = doc.body().childNodes().toList()
val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }

View file

@ -498,6 +498,7 @@ fun EpubReaderHost(
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf<String?>(null) }
var activeFootnoteHtml by remember { mutableStateOf<String?>(null) }
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
@ -509,6 +510,7 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
var removeEdgePadding by remember { mutableStateOf(loadRemoveEdgePadding(context)) }
@ -809,7 +811,6 @@ fun EpubReaderHost(
var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) }
var userStoppedTts by remember { mutableStateOf(false) }
var skipChapterRequest by remember { mutableStateOf(false) }
var ttsChapterIndex by remember { mutableStateOf<Int?>(null) }
var searchHighlightTarget by remember { mutableStateOf<SearchResult?>(null) }
@ -878,7 +879,7 @@ fun EpubReaderHost(
var activeFragmentId by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current
val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() }
val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() * pullToTurnMultiplier }
var currentScrollYPosition by rememberSaveable(epubBook.title) {
mutableIntStateOf(0)
@ -1000,20 +1001,6 @@ fun EpubReaderHost(
}
}
LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) {
skipChapterRequest = false
if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) {
Timber.d("Executing skip chapter request for continuous TTS.")
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex++
} else {
ttsShouldStartOnChapterLoad = false
}
}
}
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val speakerPlayer = remember(context, scope) {
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
@ -1135,7 +1122,7 @@ fun EpubReaderHost(
}
fun startTts() {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@ -1193,7 +1180,7 @@ fun EpubReaderHost(
)
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@ -1275,17 +1262,23 @@ fun EpubReaderHost(
ttsChapterIndex = ttsChapterIndex,
onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex },
onNavigateToChapter = { nextIndex ->
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex")
initialScrollTargetForChapter = ChapterScrollPosition.START
cfiToLoad = null
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = nextIndex
},
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
onToggleTtsStartOnLoad = { shouldStart ->
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart")
ttsShouldStartOnChapterLoad = shouldStart
},
userStoppedTts = userStoppedTts,
scope = scope,
currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() }
getAuthToken = { viewModel.getAuthToken() },
locatorConverter = locatorConverter,
epubBook = epubBook
)
TtsHighlightHandler(
@ -2411,6 +2404,8 @@ fun EpubReaderHost(
CircularProgressIndicator()
}
} else if (chapterChunks.isNotEmpty()) {
var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) }
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val targetIdx = loadUpToChunkIndex
val startIdx = 0
@ -2569,8 +2564,9 @@ fun EpubReaderHost(
Timber.d("Auto-save enabled immediately.")
}
if (ttsShouldStartOnChapterLoad) {
if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
hasRequestedExtractionForThisChapter = true
scope.launch {
delay(200)
webViewRefForTts?.evaluateJavascript(
@ -2773,7 +2769,7 @@ fun EpubReaderHost(
onTtsTextReady = { jsonString ->
scope.launch {
val token = viewModel.getAuthToken()
Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this
Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}")
val ttsChunks = mutableListOf<TtsChunk>()
try {
val jsonArray = JSONArray(jsonString)
@ -2808,18 +2804,21 @@ fun EpubReaderHost(
Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}")
if (ttsChunks.isNotEmpty()) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false
return@launch
}
ttsShouldStartOnChapterLoad = false
val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title
userStoppedTts = false
val chapterTitle = chapters.getOrNull(targetChapterIndex)?.title
val coverUriString = coverImagePath?.let {
Uri.fromFile(File(it)).toString()
}
ttsChapterIndex = currentChapterIndex
ttsChapterIndex = targetChapterIndex
ttsController.start(
chunks = ttsChunks,
bookTitle = epubBook.title,
@ -2830,12 +2829,16 @@ fun EpubReaderHost(
authToken = token
)
} else {
Timber.w("No TTS chunks were created from JSON, not starting TTS."
)
Timber.w("No TTS chunks were created from JSON, not starting TTS.")
if (ttsShouldStartOnChapterLoad) {
Timber.d("Empty chapter detected during continuous TTS. Requesting skip."
)
skipChapterRequest = true
Timber.d("Empty chapter detected during start. Advancing UI to next chapter.")
val nextIdx = targetChapterIndex + 1
if (nextIdx < chapters.size) {
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = nextIdx
}
}
}
}
@ -2899,6 +2902,9 @@ fun EpubReaderHost(
)
}
},
onFootnoteRequested = { html ->
activeFootnoteHtml = html
},
isProUser = isProUser,
isOss = BuildConfig.FLAVOR == "oss",
onShowDictionaryUpsellDialog = {
@ -3241,6 +3247,9 @@ fun EpubReaderHost(
pendingNoteForNewHighlight = true
}
},
onFootnoteRequested = { html ->
activeFootnoteHtml = html
},
onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) {
@ -4317,6 +4326,15 @@ fun EpubReaderHost(
}
}
if (activeFootnoteHtml != null) {
FootnoteBottomSheet(
htmlContent = activeFootnoteHtml!!,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
onDismiss = { activeFootnoteHtml = null }
)
}
CustomTopBanner(bannerMessage = bannerMessage)
}
}
@ -4452,6 +4470,11 @@ fun EpubReaderHost(
removeEdgePadding = it
saveRemoveEdgePadding(context, it)
},
pullToTurnMultiplier = pullToTurnMultiplier,
onPullToTurnMultiplierChange = {
pullToTurnMultiplier = it
savePullToTurnMultiplier(context, it)
},
onDismiss = { showVisualOptionsSheet = false }
)
}

View file

@ -248,6 +248,18 @@ fun loadPullToTurn(context: Context): Boolean {
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
}
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(PULL_TO_TURN_MULTIPLIER_KEY, multiplier) }
}
fun loadPullToTurnMultiplier(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -751,6 +763,8 @@ fun VisualOptionsSheet(
onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -807,21 +821,42 @@ fun VisualOptionsSheet(
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
}
AnimatedVisibility(visible = pullToTurnEnabled) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f))
Text("Pull Distance to Change Chapter", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Short", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Slider(
value = pullToTurnMultiplier,
onValueChange = onPullToTurnMultiplierChange,
valueRange = 0.5f..2.0f,
steps = 14,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp)
)
Text("Long", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
}
}

View file

@ -28,9 +28,9 @@ import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.RenderMode
@ -101,11 +101,9 @@ fun TtsSessionObserver(
chapters: List<EpubChapter>,
epubBookTitle: String,
coverImagePath: String?,
// Vertical Mode Dependencies
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
// Paginated Mode Dependencies
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
@ -115,59 +113,94 @@ fun TtsSessionObserver(
userStoppedTts: Boolean,
scope: CoroutineScope,
currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?
getAuthToken: suspend () -> String?,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW
epubBook: com.aryan.reader.epub.EpubBook // NEW
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
val currentRenderModeState = rememberUpdatedState(currentRenderMode)
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
val totalChunksInChapterState = rememberUpdatedState(totalChunksInChapter)
val ttsChapterIndexState = rememberUpdatedState(ttsChapterIndex)
val userStoppedTtsState = rememberUpdatedState(userStoppedTts)
val chaptersState = rememberUpdatedState(chapters)
val webViewRefState = rememberUpdatedState(webViewRef)
val paginatorState = rememberUpdatedState(paginator)
val pagerStateState = rememberUpdatedState(pagerState)
val onToggleTtsStartOnLoadState = rememberUpdatedState(onToggleTtsStartOnLoad)
val onNavigateToChapterState = rememberUpdatedState(onNavigateToChapter)
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
val epubBookState = rememberUpdatedState(epubBook) // NEW
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
DisposableEffect(ttsController) {
val job = scope.launch {
var wasPlaying = false
var wasSessionFinished = false
if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
ttsController.ttsState.collect { currentState ->
val isPlaying = currentState.isPlaying
val sessionFinished = currentState.sessionFinished
val sessionEndedByStop = currentState.sessionEndedByStop
val isReaderSource = currentState.playbackSource == "READER"
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
handleVerticalAutoAdvance(
webViewRef = webViewRef,
loadedChunkCount = loadedChunkCount,
totalChunksInChapter = totalChunksInChapter,
currentTtsChapterIndex = ttsChapterIndex,
totalChapters = chapters.size,
onNavigateToNextChapter = { nextIndex ->
onToggleTtsStartOnLoad(true)
onNavigateToChapter(nextIndex)
},
onStopTts = { onTtsChapterIndexChange(null) }
)
} else if (currentRenderMode == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
ttsController = ttsController,
paginator = paginator,
pagerState = pagerState,
chapters = chapters,
currentTtsChapterIndex = ttsChapterIndex,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
// Playback stopped/paused
if (userStoppedTts || sessionEndedByStop) {
Timber.d("TTS stopped by user/stop command.")
onTtsChapterIndexChange(null)
if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("TTS finished naturally. Checking for next content.")
if (currentRenderModeState.value == RenderMode.VERTICAL_SCROLL) {
handleVerticalAutoAdvance(
webViewRef = webViewRefState.value,
loadedChunkCount = loadedChunkCountState.value,
totalChunksInChapter = totalChunksInChapterState.value,
currentTtsChapterIndex = ttsChapterIndexState.value,
totalChapters = chaptersState.value.size,
onNavigateToNextChapter = { nextIndex ->
onToggleTtsStartOnLoadState.value(false)
onNavigateToChapterState.value(nextIndex)
},
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
onStopTts = { onTtsChapterIndexChangeState.value(null) },
chapters = chaptersState.value,
currentTtsMode = currentTtsMode,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
getAuthToken = getAuthToken,
ttsController = ttsController,
scope = this,
locatorConverter = locatorConverterState.value,
epubBook = epubBookState.value
)
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
ttsController = ttsController,
paginator = paginatorState.value,
pagerState = pagerStateState.value,
chapters = chaptersState.value,
currentTtsChapterIndex = ttsChapterIndexState.value,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
scope = this,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
if (userStoppedTtsState.value || sessionEndedByStop) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TTS stopped by user/stop command.")
onTtsChapterIndexChangeState.value(null)
}
}
}
wasPlaying = isPlaying
wasSessionFinished = sessionFinished
}
}
prevTtsState.value = ttsState
onDispose {
job.cancel()
}
}
}
@ -186,7 +219,6 @@ fun TtsHighlightHandler(
ttsChapterIndex: Int?,
scope: CoroutineScope
) {
// 1. Vertical & General Highlighting (WebView)
LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) {
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
@ -195,7 +227,6 @@ fun TtsHighlightHandler(
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
// Use window.highlightFromCfi defined in epub_reader.js
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
webViewRef?.evaluateJavascript(jsCommand, null)
} else {
@ -205,7 +236,6 @@ fun TtsHighlightHandler(
}
}
// 2. Paginated Page Turning (Sentence/Fragment level)
LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) {
if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect
@ -218,7 +248,7 @@ fun TtsHighlightHandler(
if (targetPage != null && targetPage != pagerState.currentPage) {
scope.launch {
pagerState.animateScrollToPage(targetPage)
pagerState.scrollToPage(targetPage)
}
}
}
@ -226,6 +256,7 @@ fun TtsHighlightHandler(
// --- Internal Helper Functions ---
@OptIn(UnstableApi::class)
private fun handleVerticalAutoAdvance(
webViewRef: WebView?,
loadedChunkCount: Int,
@ -233,20 +264,88 @@ private fun handleVerticalAutoAdvance(
currentTtsChapterIndex: Int?,
totalChapters: Int,
onNavigateToNextChapter: (Int) -> Unit,
onStopTts: () -> Unit
onUpdateTtsChapter: (Int?) -> Unit,
onStopTts: () -> Unit,
chapters: List<EpubChapter>,
currentTtsMode: TtsMode,
epubBookTitle: String,
coverImagePath: String?,
getAuthToken: suspend () -> String?,
ttsController: TtsController,
scope: CoroutineScope,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter,
epubBook: com.aryan.reader.epub.EpubBook
) {
if (loadedChunkCount < totalChunksInChapter) {
Timber.d("Vertical: Loading next chunk for TTS.")
webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}, 500)
} else {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) {
Timber.d("Vertical: Chapter finished, moving to next.")
onNavigateToNextChapter(currentTtsChapterIndex + 1)
} else {
Timber.d("Vertical: End of book.")
if (currentTtsChapterIndex == null) return
scope.launch {
val currentState = ttsController.ttsState.value
val lastReadCfi = currentState.sourceCfi
if (loadedChunkCount < totalChunksInChapter) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.")
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) {
val lastCfiPath = lastReadCfi.split(":")[0]
val resumeIdx = nativeChunks.indexOfLast { it.sourceCfi.split(":")[0] == lastCfiPath }
if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) {
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
val token = getAuthToken()
ttsController.start(
chunks = remainingChunks,
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
)
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
webViewRef?.evaluateJavascript("javascript:if(window.virtualization && window.virtualization.loadNextChunk) window.virtualization.loadNextChunk();", null)
}
return@launch
}
}
}
var nextIdx = currentTtsChapterIndex + 1
var foundContent = false
while (nextIdx < totalChapters) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Trying chapter $nextIdx natively.")
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, nextIdx)
if (!nativeChunks.isNullOrEmpty()) {
val token = getAuthToken()
onUpdateTtsChapter(nextIdx)
ttsController.start(
chunks = nativeChunks,
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
)
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
onNavigateToNextChapter(nextIdx)
}
foundContent = true
break
} else {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Chapter $nextIdx is empty natively. Skipping to next.")
nextIdx++
}
}
if (!foundContent) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Reached end of book or no more valid content.")
onStopTts()
}
}
@ -268,7 +367,7 @@ private fun handlePaginatedAutoAdvance(
getAuthToken: suspend () -> String?
) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = currentTtsChapterIndex + 1
@ -283,19 +382,18 @@ private fun handlePaginatedAutoAdvance(
while (chapterToTry < chapters.size) {
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
delay(300)
// CHANGED: Fire-and-forget scroll without frame blocking!
launch { pagerState.scrollToPage(targetPage) }
}
val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry)
if (!nextChapterChunks.isNullOrEmpty()) {
Timber.d("Paginated: Found content in chapter $chapterToTry. Starting.")
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Found content in chapter $chapterToTry. Starting.")
onUpdateTtsChapter(chapterToTry)
val chapterTitle = chapters.getOrNull(chapterToTry)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
val token = getAuthToken()
ttsController.start(
@ -310,20 +408,13 @@ private fun handlePaginatedAutoAdvance(
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {
pagerState.animateScrollToPage(targetPage!! + i)
delay(400)
}
}
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Chapter $chapterToTry is empty. Skipping.")
chapterToTry++
}
}
if (!foundContent) {
Timber.d("Paginated: No more content found.")
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: No more content found.")
onUpdateTtsChapter(null)
}
}

View file

@ -69,6 +69,15 @@ class InteractiveWebView(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed")
val hitTestResult = this@InteractiveWebView.hitTestResult
val type = hitTestResult.type
if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
Timber.d("Tap was on a link. Consuming tap, not toggling app bars.")
return true
}
onSingleTap()
return true
}
@ -117,12 +126,16 @@ class InteractiveWebView(
}
if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) {
Timber.d("Drag operation started ($currentDragOperation), disabling text selection."
)
Timber.d("Drag operation started ($currentDragOperation), disabling text selection.")
evaluateJavascript(
"javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);",
null
)
val cancelEvent = MotionEvent.obtain(event)
cancelEvent.action = MotionEvent.ACTION_CANCEL
super.onTouchEvent(cancelEvent)
cancelEvent.recycle()
}
}