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:
parent
4cb25e6abd
commit
71e3614ad6
19 changed files with 1413 additions and 287 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue