Update v1.0.49 (#330)

* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -468,6 +468,9 @@ fun ChapterWebView(
ttsScope: CoroutineScope,
tocFragments: List<String>,
initialFragmentId: String? = null,
initialImageSource: String? = null,
initialImageOriginalSource: String? = null,
initialImageOrdinal: Int = 0,
onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean,
isOss: Boolean = false,
@ -1011,6 +1014,14 @@ fun ChapterWebView(
}
onChapterInitiallyScrolled()
scrollActionTaken = true
} else if (!initialImageSource.isNullOrBlank()) {
val imageJsCommand =
"javascript:window.scrollToReaderImageSource('${escapeJsString(initialImageSource)}', $initialImageOrdinal, '${escapeJsString(initialImageOriginalSource.orEmpty())}');"
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to image source: $initialImageSource")
view?.evaluateJavascript(imageJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (initialScrollTarget != null) {
val scrollJsCommand = when (initialScrollTarget) {
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"

View file

@ -41,6 +41,7 @@ import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.summarizationUrl
@ -209,8 +210,7 @@ suspend fun executeRecapLogic(
val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) {
try {
val chapter = chapters[i]
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val doc = Jsoup.parse(File(fullPath), "UTF-8")
val doc = Jsoup.parse(File(epubBook.extractionBasePath, chapter.contentFilePath()), "UTF-8")
doc.body().text()
} catch (_: Exception) { "" }
}

View file

@ -23,10 +23,13 @@ import android.content.Context
import com.aryan.reader.R
import timber.log.Timber
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.LocatorConverter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import java.io.File
data class ChapterLoadingResult(
@ -34,9 +37,44 @@ data class ChapterLoadingResult(
val chunks: List<String>,
val startChunkIndex: Int,
val isSuccess: Boolean,
val errorMessage: String? = null
val errorMessage: String? = null,
val chunkElementStartIndices: List<Int> = emptyList(),
val chunkElementCounts: List<Int> = emptyList()
)
internal data class ReaderHtmlChunk(
val html: String,
val elementStartIndex: Int,
val elementCount: Int
)
internal fun splitBodyNodesIntoReaderChunks(
bodyNodes: List<Node>,
chunkSize: Int = 20
): List<ReaderHtmlChunk> {
var elementStartIndex = 0
return bodyNodes.chunked(chunkSize).map { nodes ->
val elementCount = nodes.count { it is Element }
ReaderHtmlChunk(
html = nodes.joinToString(separator = "\n") { it.outerHtml() },
elementStartIndex = elementStartIndex,
elementCount = elementCount
).also {
elementStartIndex += elementCount
}
}
}
internal fun readerChunkContainerAttributes(
index: Int,
chunkElementStartIndices: List<Int>,
chunkElementCounts: List<Int>
): String {
val startIndex = chunkElementStartIndices.getOrElse(index) { index * 20 }
val elementCount = chunkElementCounts.getOrElse(index) { 20 }
return "data-chunk-index='$index' data-element-start-index='$startIndex' data-element-count='$elementCount'"
}
/**
* loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc.).
@ -56,24 +94,36 @@ suspend fun loadChapterContent(
)
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
val (headContent, chunks) = if (htmlFile.exists()) {
val (headContent, chunks, chunkElementStartIndices, chunkElementCounts) = 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() }
}
if (chunkedList.isEmpty()) {
head to listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>")
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
if (htmlChunks.isEmpty()) {
ChapterHtmlPayload(
head = head,
chunks = listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>"),
chunkElementStartIndices = listOf(0),
chunkElementCounts = listOf(1)
)
} else {
head to chunkedList
ChapterHtmlPayload(
head = head,
chunks = htmlChunks.map { it.html },
chunkElementStartIndices = htmlChunks.map { it.elementStartIndex },
chunkElementCounts = htmlChunks.map { it.elementCount }
)
}
} else {
"" to listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>")
ChapterHtmlPayload(
head = "",
chunks = listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>"),
chunkElementStartIndices = listOf(0),
chunkElementCounts = listOf(1)
)
}
var targetChunk = 0
@ -101,7 +151,9 @@ suspend fun loadChapterContent(
head = headContent,
chunks = chunks,
startChunkIndex = targetChunk,
isSuccess = true
isSuccess = true,
chunkElementStartIndices = chunkElementStartIndices,
chunkElementCounts = chunkElementCounts
)
} catch (e: Exception) {
@ -114,4 +166,11 @@ suspend fun loadChapterContent(
errorMessage = e.message
)
}
}
}
private data class ChapterHtmlPayload(
val head: String,
val chunks: List<String>,
val chunkElementStartIndices: List<Int>,
val chunkElementCounts: List<Int>
)

View file

@ -19,9 +19,11 @@
*/
package com.aryan.reader.epubreader
import android.graphics.BitmapFactory
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
@ -32,6 +34,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@ -55,6 +58,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
@ -66,12 +70,13 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -83,7 +88,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -94,8 +101,10 @@ import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@Composable
@ -207,6 +216,7 @@ fun EpubReaderDrawerSheet(
chapters: List<EpubChapter>,
tableOfContents: List<EpubTocEntry>,
activeFragmentId: String?,
readerImages: List<EpubReaderImageReference>,
bookmarks: Set<Bookmark>,
userHighlights: List<UserHighlight>,
currentChapterIndex: Int,
@ -214,6 +224,8 @@ fun EpubReaderDrawerSheet(
renderMode: RenderMode,
onNavigateToChapter: (Int) -> Unit,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToImage: (EpubReaderImageReference) -> Unit,
onDownloadImage: (EpubReaderImageReference) -> Unit,
onNavigateToBookmark: (Bookmark) -> Unit,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit,
@ -227,11 +239,15 @@ fun EpubReaderDrawerSheet(
ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
ScrollableTabRow(
selectedTabIndex = drawerPagerState.currentPage,
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Tab(
selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
@ -247,6 +263,11 @@ fun EpubReaderDrawerSheet(
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text(stringResource(R.string.tab_annotations)) }
)
Tab(
selected = drawerPagerState.currentPage == 3,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(3) } },
text = { Text(stringResource(R.string.tab_images)) }
)
}
HorizontalPager(
@ -282,6 +303,11 @@ fun EpubReaderDrawerSheet(
onOpenPaletteManager = onOpenPaletteManager,
onHighlightColorChange = onHighlightColorChange
)
3 -> ImagesList(
readerImages = readerImages,
onNavigateToImage = onNavigateToImage,
onDownloadImage = onDownloadImage
)
}
}
}
@ -710,6 +736,145 @@ private fun BookmarksList(
}
}
@Composable
private fun ImagesList(
readerImages: List<EpubReaderImageReference>,
onNavigateToImage: (EpubReaderImageReference) -> Unit,
onDownloadImage: (EpubReaderImageReference) -> Unit
) {
if (readerImages.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.no_images_found),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
return
}
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(end = 4.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(
items = readerImages,
key = { it.id }
) { image ->
ListItem(
leadingContent = {
EpubReaderImageThumbnail(
image = image,
modifier = Modifier.size(width = 72.dp, height = 56.dp)
)
},
headlineContent = {
Text(
text = image.displayTitle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = {
Column {
Text(
text = image.chapterTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val metadata = listOfNotNull(image.dimensionLabel, image.sourceName()).joinToString(" - ")
if (metadata.isNotBlank()) {
Text(
text = metadata,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
trailingContent = {
IconButton(onClick = { onDownloadImage(image) }) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.content_desc_download_image)
)
}
},
modifier = Modifier.clickable { onNavigateToImage(image) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
@Composable
private fun EpubReaderImageThumbnail(
image: EpubReaderImageReference,
modifier: Modifier = Modifier
) {
var bitmap by remember(image.sourcePath) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(image.sourcePath) {
bitmap = withContext(Dispatchers.IO) {
if (image.sourcePath.startsWith("data:", ignoreCase = true)) {
val bytes = image.readDownloadBytes()
bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} else {
BitmapFactory.decodeFile(image.sourcePath)
}
}
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
) {
val currentBitmap = bitmap
if (currentBitmap != null) {
Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = (image.index + 1).toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun HighlightsList(

View file

@ -0,0 +1,243 @@
package com.aryan.reader.epubreader
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Base64
data class EpubReaderImageReference(
val id: String,
val index: Int,
val sourcePath: String,
val originalSource: String,
val altText: String?,
val chapterIndex: Int,
val chapterTitle: String,
val elementId: String?,
val ordinalInChapter: Int,
val chunkIndex: Int?,
val intrinsicWidth: Int?,
val intrinsicHeight: Int?
) {
val displayTitle: String
get() = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "Image ${index + 1}"
val dimensionLabel: String?
get() {
val width = intrinsicWidth?.takeIf { it > 0 }
val height = intrinsicHeight?.takeIf { it > 0 }
return if (width != null && height != null) "${width}x$height" else null
}
fun sourceName(): String? {
val source = originalSource.takeIf { it.isNotBlank() } ?: sourcePath
if (source.startsWith("data:", ignoreCase = true)) return null
return source
.substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.substringAfterLast('/')
.takeIf { it.isNotBlank() }
}
fun suggestedDownloadFileName(): String {
val extension = sourcePath.readerImageExtension()
?: originalSource.readerImageExtension()
?: "png"
val base = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "image-${index + 1}"
val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
return "$safeBase.$extension"
}
fun mimeType(): String {
val dataMime = readerDataUriMimeType(sourcePath)
if (dataMime != null) return dataMime
return when (sourcePath.readerImageExtension() ?: originalSource.readerImageExtension()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"bmp" -> "image/bmp"
"svg" -> "image/svg+xml"
else -> "image/*"
}
}
}
fun EpubBook.readerImageReferencesForDrawer(): List<EpubReaderImageReference> {
val references = mutableListOf<EpubReaderImageReference>()
chapters.forEachIndexed { chapterIndex, chapter ->
val html = chapter.readerImageHtml(extractionBasePath).takeIf { it.isNotBlank() }
?: return@forEachIndexed
val sourceOrdinalByKey = mutableMapOf<String, Int>()
val document = Jsoup.parse(html, chapter.absPath)
document.select("img, image").forEach { element ->
val originalSource = element.readerImageSource() ?: return@forEach
val sourcePath = resolveReaderImageSource(chapter, extractionBasePath, originalSource)
val sourceKey = sourcePath.readerImageLookupKey()
val ordinal = sourceOrdinalByKey.getOrDefault(sourceKey, 0)
sourceOrdinalByKey[sourceKey] = ordinal + 1
val index = references.size
references += EpubReaderImageReference(
id = "android-epub-image:$chapterIndex:$ordinal:${sourcePath.hashCode()}:$index",
index = index,
sourcePath = sourcePath,
originalSource = originalSource,
altText = element.attr("alt").ifBlank { element.attr("title") }.ifBlank { null },
chapterIndex = chapterIndex,
chapterTitle = chapter.title.ifBlank { "Chapter ${chapterIndex + 1}" },
elementId = element.id().ifBlank { null },
ordinalInChapter = ordinal,
chunkIndex = element.readerTopLevelBodyChildIndex()?.let { it / 20 },
intrinsicWidth = element.readerImageDimension("width"),
intrinsicHeight = element.readerImageDimension("height")
)
}
}
return references
}
fun EpubReaderImageReference.readDownloadBytes(): ByteArray? {
if (sourcePath.startsWith("data:", ignoreCase = true)) {
return sourcePath.readerDataUriBytes()
}
return runCatching {
File(sourcePath).takeIf { it.isFile }?.readBytes()
}.getOrNull()
}
private fun EpubChapter.readerImageHtml(extractionBasePath: String): String {
if (htmlContent.isNotBlank()) return htmlContent
return runCatching {
File(extractionBasePath, htmlFilePath).takeIf { it.isFile }?.readText().orEmpty()
}.getOrDefault("")
}
private fun Element.readerImageSource(): String? {
return listOf("src", "href", "xlink:href", "data-src")
.firstNotNullOfOrNull { attrName ->
attr(attrName).trim().takeIf { it.isNotBlank() }
}
}
private fun Element.readerImageDimension(attribute: String): Int? {
val raw = attr(attribute).trim().takeIf { it.isNotBlank() } ?: return null
return Regex("""\d+""").find(raw)?.value?.toIntOrNull()?.takeIf { it > 0 }
}
private fun Element.readerTopLevelBodyChildIndex(): Int? {
val body = ownerDocument()?.body() ?: return null
var topLevel: Element = this
while (topLevel.parent() != null && topLevel.parent() != body) {
topLevel = topLevel.parent() ?: break
}
if (topLevel.parent() != body) return null
return body.childNodes().indexOf(topLevel).takeIf { it >= 0 }
}
private fun resolveReaderImageSource(
chapter: EpubChapter,
extractionBasePath: String,
source: String
): String {
val withoutFragment = source.substringBefore('#').substringBefore('?')
if (withoutFragment.startsWith("data:", ignoreCase = true)) return source
val fileUriPath = withoutFragment.readerFileUriPath()
fileUriPath?.let { path ->
val file = File(path)
if (file.isFile) {
return runCatching { file.canonicalFile.absolutePath }.getOrDefault(file.absolutePath)
}
}
val sourceForResolve = fileUriPath ?: withoutFragment
AndroidHtmlResourceResolver.resolvePath(chapter.absPath, extractionBasePath, sourceForResolve)?.let {
return it
}
val fallbackCandidates = listOf(
File(extractionBasePath, sourceForResolve),
File(extractionBasePath, sourceForResolve.trimStart('/', '\\'))
)
return fallbackCandidates
.firstOrNull { it.isFile }
?.let { runCatching { it.canonicalFile.absolutePath }.getOrDefault(it.absolutePath) }
?: source
}
private fun String.readerFileUriPath(): String? {
if (!startsWith("file:", ignoreCase = true)) return null
return runCatching {
URI(this).path?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }
}.getOrNull()
}
private fun String.readerImageLookupKey(): String {
return substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.lowercase()
}
private fun String.readerImageExtension(): String? {
readerDataUriMimeType(this)?.let { mime ->
return when (mime.lowercase()) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
"image/bmp" -> "bmp"
"image/svg+xml" -> "svg"
else -> null
}
}
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase()
.takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
}
private fun readerDataUriMimeType(source: String): String? {
if (!source.startsWith("data:", ignoreCase = true)) return null
return source
.drop(5)
.substringBefore(';')
.substringBefore(',')
.takeIf { it.startsWith("image/", ignoreCase = true) }
}
private fun String.readerDataUriBytes(): ByteArray? {
val commaIndex = indexOf(',')
if (!startsWith("data:", ignoreCase = true) || commaIndex == -1) return null
val metadata = substring(0, commaIndex)
val data = substring(commaIndex + 1)
return runCatching {
if (metadata.contains(";base64", ignoreCase = true)) {
Base64.getDecoder().decode(data)
} else {
URLDecoder.decode(data, StandardCharsets.UTF_8.name()).toByteArray(StandardCharsets.UTF_8)
}
}.getOrNull()
}
private fun String.sanitizedReaderImageFileBase(): String {
return replace(Regex("""[\\/:*?"<>|]+"""), "_")
.replace(Regex("""\s+"""), " ")
.trim()
.trim('.')
.take(80)
}

View file

@ -158,6 +158,9 @@ import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderBrightnessEffect
import com.aryan.reader.ReaderFileInfoDialogs
import com.aryan.reader.ReaderBrightnessSheet
import com.aryan.reader.ReaderScreenOrientationEffect
import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel
@ -176,13 +179,17 @@ import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadEpubRightToLeftPagination
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderSliderToggled
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock
@ -198,10 +205,13 @@ import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderBrightnessSettings
import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.saveEpubRightToLeftPagination
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveReaderSliderToggled
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shouldRenderReaderSlider
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.tts.SpeakerSamplePlayer
@ -210,6 +220,7 @@ import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import com.aryan.reader.shared.reader.ReaderJumpHistory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -217,6 +228,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
@ -246,7 +258,7 @@ private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
@ -264,6 +276,25 @@ private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String
.take(maxLength)
}
private fun List<TtsChunk>.withInitialChunkOverride(
startChunkIndex: Int,
initialChunk: TtsChunk?
): List<TtsChunk> {
if (initialChunk == null || startChunkIndex !in indices) return this
val existing = this[startChunkIndex]
if (
existing.text == initialChunk.text &&
existing.sourceCfi == initialChunk.sourceCfi &&
existing.startOffsetInSource == initialChunk.startOffsetInSource
) {
return this
}
return toMutableList().also { chunks ->
chunks[startChunkIndex] = initialChunk
}
}
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
@ -313,7 +344,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion)
prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
@ -323,6 +354,13 @@ private fun loadHiddenTools(context: Context): Set<String> {
return savedHiddenTools
}
private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
return buildSet {
if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name)
if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name)
}
}
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
@ -644,9 +682,19 @@ fun EpubReaderHost(
) {
val view = LocalView.current
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val window = (view.context as? Activity)?.window
val activity = context as? Activity
val scope = rememberCoroutineScope()
var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) }
var showBrightnessSheet by remember { mutableStateOf(false) }
ReaderBrightnessEffect(window, readerBrightnessSettings)
val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
readerBrightnessSettings = settings
saveReaderBrightnessSettings(context, settings)
}
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
viewModel.showBanner(message, isError, isPersistent)
}
@ -663,8 +711,8 @@ fun EpubReaderHost(
var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
var showFileInfoDialog by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
@ -717,6 +765,11 @@ fun EpubReaderHost(
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val bookId = readerCacheBookId
var isPageSliderVisible by remember(bookId) {
mutableStateOf(loadReaderSliderToggled(context, bookId))
}
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
@ -749,7 +802,6 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@ -1017,6 +1069,13 @@ fun EpubReaderHost(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showBars by remember { mutableStateOf(false) }
val chapters = remember(epubBook.chapters) { epubBook.chapters }
var readerImages by remember(epubBook) { mutableStateOf<List<EpubReaderImageReference>>(emptyList()) }
LaunchedEffect(epubBook) {
readerImages = withContext(Dispatchers.IO) {
epubBook.readerImageReferencesForDrawer()
}
}
var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf(
@ -1048,11 +1107,14 @@ fun EpubReaderHost(
var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) }
var chapterChunks by remember(currentChapterIndex) { mutableStateOf<List<String>>(emptyList()) }
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
var fragmentToLoad by remember { mutableStateOf<String?>(null) }
var imageToLoad by remember { mutableStateOf<EpubReaderImageReference?>(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
@ -1170,13 +1232,6 @@ fun EpubReaderHost(
}
}
LaunchedEffect(isPageSliderVisible) {
if (!isPageSliderVisible) {
startPageThumbnail?.recycle()
startPageThumbnail = null
}
}
LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message ->
if (message == "INSUFFICIENT_CREDITS") {
@ -1189,6 +1244,12 @@ fun EpubReaderHost(
}
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0
val epubSliderChromeVisible = shouldRenderReaderSlider(
isToggledOn = isPageSliderVisible,
isBottomChromeVisible = showBars,
isSearchActive = searchState.isSearchActive
) && isEpubSliderReady
val speakerPlayer = remember(context, scope) {
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
}
@ -1282,6 +1343,11 @@ fun EpubReaderHost(
if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000)
} else activeTheme.textColor
}
val epubReaderSliderColors = readerSliderChromeColors(
pageBackground = effectiveBg,
pageText = effectiveText,
themePrimary = MaterialTheme.colorScheme.primary
)
val activeTextureId = activeTheme.textureId
val activeTextureAlpha = 1f - globalTextureTransparency
val activeTextureBitmap = remember(activeTextureId) {
@ -1326,7 +1392,8 @@ fun EpubReaderHost(
!ttsState.currentWordSourceCfi.isNullOrBlank() ||
!ttsState.sourceCfi.isNullOrBlank() ||
!ttsState.currentText.isNullOrBlank()
val isSameBook = ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title
val isSameBook = ttsState.bookId?.let { it == bookId }
?: (ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title)
return isReaderSession && hasReaderSessionState && isSameBook
}
@ -1372,6 +1439,7 @@ fun EpubReaderHost(
chunkTargetOverride = null
cfiToLoad = null
fragmentToLoad = null
imageToLoad = null
isNavigatingToPosition = false
suppressNextVerticalTtsDetach = false
}
@ -1570,22 +1638,31 @@ fun EpubReaderHost(
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
val pageInChapter = currentPage - chapterStartPage
val ttsChunks = bookPaginator.getTtsChunksForChapter(
chapterIndex = chapterIndex,
startingFromPageInChapter = pageInChapter
)
val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex)
val firstChunkOnPage = if (pageInChapter > 0) {
bookPaginator.getTtsChunksForChapter(
chapterIndex = chapterIndex,
startingFromPageInChapter = pageInChapter
)?.firstOrNull()
} else {
allTtsChunks?.firstOrNull()
}
val startChunkIndex = findTtsChunkStartIndex(allTtsChunks.orEmpty(), firstChunkOnPage) ?: 0
if (!ttsChunks.isNullOrEmpty()) {
if (!allTtsChunks.isNullOrEmpty() && firstChunkOnPage != null) {
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex
ttsController.start(
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
chunks = allTtsChunks.withInitialChunkOverride(startChunkIndex, firstChunkOnPage)
.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = chapterIndex,
totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -1597,6 +1674,33 @@ fun EpubReaderHost(
)
}
var pendingImageDownload by remember { mutableStateOf<EpubReaderImageReference?>(null) }
val imageSaveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("image/*"),
onResult = { uri ->
val image = pendingImageDownload
pendingImageDownload = null
if (uri != null && image != null) {
scope.launch {
val saved = withContext(Dispatchers.IO) {
runCatching {
val bytes = image.readDownloadBytes() ?: error("Image bytes are unavailable")
context.contentResolver.openOutputStream(uri)?.use { output ->
output.write(bytes)
} ?: error("Could not open image destination")
}.isSuccess
}
val message = if (saved) {
context.getString(R.string.saved_image_message, image.suggestedDownloadFileName())
} else {
context.getString(R.string.error_save_image)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
}
)
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { _ ->
@ -1616,17 +1720,14 @@ fun EpubReaderHost(
val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
var foundIdx = -1
for (i in chunks.indices) {
val c = chunks[i]
val cPath = CfiUtils.getPath(c.sourceCfi)
val bPath = CfiUtils.getPath(baseCfi)
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) {
foundIdx = i
break
}
}
val foundIdx = findTtsChunkStartIndex(
chunks = chunks,
target = TtsChunk(
text = "",
sourceCfi = baseCfi,
startOffsetInSource = startOffset
)
) ?: -1
if (foundIdx != -1) {
val target = chunks[foundIdx]
@ -1639,21 +1740,24 @@ fun EpubReaderHost(
spokenText = slicedText,
)
val remainingChunks = mutableListOf(newChunk)
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
val sessionChunks = chunks.toMutableList().also {
it[foundIdx] = newChunk
}
if (remainingChunks.isNotEmpty()) {
if (sessionChunks.isNotEmpty()) {
ttsShouldStartOnChapterLoad = false
ttsChapterIndex = chapterIndex
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = chapterIndex,
totalChapters = chapters.size,
startChunkIndex = foundIdx,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -1770,6 +1874,63 @@ fun EpubReaderHost(
}
}
fun currentEpubSliderPage(): Int {
return when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> currentPageInChapter
RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1)
}
}
fun resetEpubSliderBookmark() {
val position = readerSliderBookmarkPosition(currentEpubSliderPage())
sliderStartPage = position.startPage
sliderCurrentPage = position.currentPage
}
LaunchedEffect(bookId, isPageSliderVisible) {
saveReaderSliderToggled(context, bookId, isPageSliderVisible)
if (isPageSliderVisible) {
resetEpubSliderBookmark()
}
}
fun toggleEpubPageSlider() {
if (!isPageSliderVisible && currentRenderMode == RenderMode.PAGINATED && paginatedPagerState.pageCount <= 0) {
showBanner("Book is not paginated yet.")
return
}
val nextState = readerSliderToggleState(
isCurrentlyToggledOn = isPageSliderVisible,
currentPage = currentEpubSliderPage()
)
sliderStartPage = nextState.bookmarkPosition.startPage
sliderCurrentPage = nextState.bookmarkPosition.currentPage
isPageSliderVisible = nextState.isToggledOn
showBars = true
if (nextState.isToggledOn) {
showFormatAdjustmentBars = false
}
}
LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) {
if (isPageSliderVisible && !epubSliderChromeVisible) {
resetEpubSliderBookmark()
}
}
LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) {
if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.recycle()
startPageThumbnail = webViewRefForTts?.let { webView ->
captureWebViewVisibleArea(webView)
}
} else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) {
startPageThumbnail?.recycle()
startPageThumbnail = null
}
}
val latestChapterIndex by rememberUpdatedState(currentChapterIndex)
LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) {
@ -1952,6 +2113,8 @@ fun EpubReaderHost(
webViewRefForTts = null
chapterHead = ""
chapterChunks = emptyList()
chapterChunkElementStartIndices = emptyList()
chapterChunkElementCounts = emptyList()
startPageThumbnail?.recycle()
startPageThumbnail = null
autoScrollResumeJob.value?.cancel()
@ -2029,6 +2192,8 @@ fun EpubReaderHost(
chapterHead = result.head
chapterChunks = result.chunks
chapterChunkElementStartIndices = result.chunkElementStartIndices
chapterChunkElementCounts = result.chunkElementCounts
isChapterParsing = false
if (initialScrollTargetForChapter == ChapterScrollPosition.END) {
@ -2505,6 +2670,42 @@ fun EpubReaderHost(
}
}
fun scrollCurrentVerticalChapterToImage(image: EpubReaderImageReference) {
val targetChunk = image.chunkIndex
if (targetChunk != null && targetChunk >= 0) {
injectVerticalChunksThrough(targetChunk)
}
val escapedSource = escapeJsString(image.sourcePath)
val escapedOriginalSource = escapeJsString(image.originalSource)
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToReaderImageSource('$escapedSource', ${image.ordinalInChapter}, '$escapedOriginalSource');",
null
)
}
fun navigateVerticalToImage(image: EpubReaderImageReference) {
scope.launch {
recordEpubJump(chapterStartJumpLocator(image.chapterIndex))
clearPendingTtsRelocationState("sidebar_image_vertical")
imageToLoad = image
cfiToLoad = null
fragmentToLoad = null
initialScrollTargetForChapter = null
if (image.chapterIndex != currentChapterIndex) {
chunkTargetOverride = image.chunkIndex?.coerceAtLeast(0)
Timber.tag(TAG_LINK_NAV)
.d("[CHAPTER-NAV] source=SIDEBAR_IMAGE, from=$currentChapterIndex, to=${image.chapterIndex}, image='${image.sourceName()}'")
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = image.chapterIndex
} else {
chunkTargetOverride = null
scrollCurrentVerticalChapterToImage(image)
imageToLoad = null
}
}
}
fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) {
scope.launch {
val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi)
@ -2766,10 +2967,7 @@ fun EpubReaderHost(
}
BackHandler(enabled = true) {
if (isPageSliderVisible) {
isPageSliderVisible = false
showBars = true
} else if (drawerState.isOpen) {
if (drawerState.isOpen) {
scope.launch {
Timber.d("Back pressed: Closing drawer")
drawerState.close()
@ -2795,6 +2993,7 @@ fun EpubReaderHost(
chapters = chapters,
tableOfContents = epubBook.tableOfContents,
activeFragmentId = activeFragmentId,
readerImages = readerImages,
bookmarks = bookmarks,
userHighlights = userHighlights,
currentChapterIndex = currentChapterIndex,
@ -2803,6 +3002,56 @@ fun EpubReaderHost(
activeHighlightPalette = currentHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true },
onHighlightColorChange = onHighlightColorChange,
onNavigateToImage = { image ->
scope.launch {
drawerState.close()
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
navigateVerticalToImage(image)
}
RenderMode.PAGINATED -> {
val bookPaginator = paginator as? BookPaginator
if (bookPaginator != null) {
isNavigatingByToc = true
try {
val imagePage = bookPaginator.findStablePageForImageSource(
chapterIndex = image.chapterIndex,
sourcePath = image.sourcePath,
elementId = image.elementId,
ordinalInChapter = image.ordinalInChapter
)
if (imagePage != null) {
val (pageIndex, locator) = imagePage
paginatedJumpLocatorForPage(
pageIndex = pageIndex,
targetLocator = locator,
allowPageFallback = true
)?.let { recordEpubJump(it) }
scrollPaginatedToJumpPage(pageIndex, locator)
} else {
val fallbackPage = bookPaginator.findStableChapterStartPage(image.chapterIndex)
if (fallbackPage != null) {
recordEpubJump(chapterStartJumpLocator(image.chapterIndex).copy(pageIndex = fallbackPage))
scrollPaginatedToJumpPage(
fallbackPage,
Locator(image.chapterIndex, 0, 0),
fallbackToChapterStart = true
)
}
}
} finally {
isNavigatingByToc = false
}
}
}
}
if (showBars) showBars = false
}
},
onDownloadImage = { image ->
pendingImageDownload = image
imageSaveLauncher.launch(image.suggestedDownloadFileName())
},
onNavigateToTocEntry = { entry ->
scope.launch {
drawerState.close()
@ -3434,6 +3683,10 @@ fun EpubReaderHost(
currentTopPadding
}
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
Box(
modifier = Modifier
.fillMaxSize()
@ -3545,16 +3798,26 @@ fun EpubReaderHost(
} else if (chapterChunks.isNotEmpty()) {
var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) }
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val initialContentToLoad = remember(
loadUpToChunkIndex,
chapterChunks,
chapterChunkElementStartIndices,
chapterChunkElementCounts
) {
val targetIdx = loadUpToChunkIndex
val startIdx = 0
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
chapterChunks.indices.joinToString(separator = "\n") { index ->
val attributes = readerChunkContainerAttributes(
index,
chapterChunkElementStartIndices,
chapterChunkElementCounts
)
if (index in startIdx..endIdx) {
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
"<div class='chunk-container' $attributes>${chapterChunks[index]}</div>"
} else {
"<div class='chunk-container' data-chunk-index='$index'></div>"
"<div class='chunk-container' $attributes></div>"
}
}
}
@ -3651,6 +3914,9 @@ fun EpubReaderHost(
initialPageScrollY = currentScrollYPosition,
initialCfi = cfiToLoad,
initialFragmentId = fragmentToLoad.also { },
initialImageSource = imageToLoad?.sourcePath,
initialImageOriginalSource = imageToLoad?.originalSource,
initialImageOrdinal = imageToLoad?.ordinalInChapter ?: 0,
userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex },
activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette,
@ -3693,12 +3959,14 @@ fun EpubReaderHost(
)
} else {
val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
val wasImageScroll = imageToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll wasImageScroll=$wasImageScroll")
initialScrollTargetForChapter = null
cfiToLoad = null
fragmentToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
imageToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
isWebViewReady = true
if (wasCfiScroll) {
@ -4154,14 +4422,27 @@ fun EpubReaderHost(
Uri.fromFile(File(it)).toString()
}
ttsChapterIndex = targetChapterIndex
val nativeChapterChunks = locatorConverter
.getTtsChunksForChapter(epubBook, targetChapterIndex, bookId)
.orEmpty()
val extractedStartChunk = ttsChunks.firstOrNull()
val nativeStartChunkIndex = findTtsChunkStartIndex(nativeChapterChunks, extractedStartChunk)
val sessionChunks = if (nativeChapterChunks.isNotEmpty() && nativeStartChunkIndex != null) {
nativeChapterChunks.withInitialChunkOverride(nativeStartChunkIndex, extractedStartChunk)
} else {
ttsChunks
}
val startChunkIndex = nativeStartChunkIndex ?: 0
ttsController.start(
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = targetChapterIndex,
totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -5216,6 +5497,7 @@ fun EpubReaderHost(
currentRenderMode = currentRenderMode,
isBookmarked = isBookmarked,
isTtsActive = isTtsSessionActive,
isSliderActive = isPageSliderVisible,
tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
@ -5307,35 +5589,11 @@ fun EpubReaderHost(
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenBrightness = { showBrightnessSheet = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
onOpenScreenOrientation = { showScreenOrientationSheet = true },
onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
sliderStartPage = currentPageInChapter
sliderCurrentPage = currentPageInChapter.toFloat()
isPageSliderVisible = true
showBars = false
scope.launch {
webViewRefForTts?.let { webView ->
startPageThumbnail = captureWebViewVisibleArea(webView)
}
}
}
RenderMode.PAGINATED -> {
if (paginatedPagerState.pageCount > 0) {
sliderStartPage = paginatedPagerState.currentPage + 1
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
isPageSliderVisible = true
showBars = false
startPageThumbnail = null
} else {
showBanner("Book is not paginated yet.")
}
}
}
},
onOpenSlider = ::toggleEpubPageSlider,
onOpenDrawer = {
scope.launch { drawerState.open() }
},
@ -5343,6 +5601,7 @@ fun EpubReaderHost(
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false
resetEpubSliderBookmark()
isPageSliderVisible = false
}
},
@ -5374,6 +5633,7 @@ fun EpubReaderHost(
}
}
},
onOpenFileInfo = { showFileInfoDialog = true },
onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -5537,8 +5797,8 @@ fun EpubReaderHost(
.padding(bottom = bottomPadding + 45.dp),
showStandardBars = showBars,
searchStateActive = searchState.isSearchActive,
backLabel = epubJumpHistory.backLocator?.epubJumpLabel(),
forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(),
backLabel = epubJumpBackLabel,
forwardLabel = epubJumpForwardLabel,
onBack = ::goBackInEpubJumpHistory,
onForward = ::goForwardInEpubJumpHistory,
onClear = { epubJumpHistory = epubJumpHistory.clear() }
@ -5555,35 +5815,12 @@ fun EpubReaderHost(
toolOrder = toolOrder,
bottomTools = bottomTools,
currentTtsMode = currentTtsMode,
isSliderActive = isPageSliderVisible,
onOpenAiHub = { showAiHubSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
sliderStartPage = currentPageInChapter
sliderCurrentPage = currentPageInChapter.toFloat()
isPageSliderVisible = true
showBars = false
scope.launch {
webViewRefForTts?.let { webView ->
startPageThumbnail = captureWebViewVisibleArea(webView)
}
}
}
RenderMode.PAGINATED -> {
if (paginatedPagerState.pageCount > 0) {
sliderStartPage = paginatedPagerState.currentPage + 1
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
isPageSliderVisible = true
showBars = false
startPageThumbnail = null
} else {
showBanner("Book is not paginated yet.")
}
}
}
},
onOpenBrightness = { showBrightnessSheet = true },
onOpenSlider = ::toggleEpubPageSlider,
onOpenDrawer = {
scope.launch { drawerState.open() }
},
@ -5592,6 +5829,7 @@ fun EpubReaderHost(
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false
resetEpubSliderBookmark()
isPageSliderVisible = false
}
},
@ -5880,69 +6118,62 @@ fun EpubReaderHost(
onDismiss = { activeFootnoteHtml = null }
)
}
}
}
EpubReaderPageSlider(
isVisible = isPageSliderVisible,
currentRenderMode = currentRenderMode,
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
sliderCurrentPage = sliderCurrentPage,
sliderStartPage = sliderStartPage,
startPageThumbnail = startPageThumbnail,
paginator = paginator,
chapters = chapters,
onClose = {
isPageSliderVisible = false
showBars = true
},
onScrub = { newValue ->
sliderCurrentPage = newValue
isFastScrubbing = true
scrubDebounceJob.value?.cancel()
scrubDebounceJob.value = scope.launch {
delay(200)
if (isActive) {
val targetPage = newValue.roundToInt()
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
val scrollY = (targetPage - 1) * currentClientHeightValue
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
} else {
paginatedPagerState.scrollToPage(targetPage - 1)
EpubReaderPageSlider(
isVisible = epubSliderChromeVisible,
currentRenderMode = currentRenderMode,
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
sliderCurrentPage = sliderCurrentPage,
sliderStartPage = sliderStartPage,
startPageThumbnail = startPageThumbnail,
paginator = paginator,
chapters = chapters,
onScrub = { newValue ->
sliderCurrentPage = newValue
isFastScrubbing = true
scrubDebounceJob.value?.cancel()
scrubDebounceJob.value = scope.launch {
delay(200)
if (isActive) {
val targetPage = newValue.roundToInt()
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
val scrollY = (targetPage - 1) * currentClientHeightValue
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
} else {
paginatedPagerState.scrollToPage(targetPage - 1)
}
isFastScrubbing = false
}
}
isFastScrubbing = false
}
}
},
onJumpToPage = { page ->
scope.launch {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
sliderCurrentPage = page.toFloat()
val scrollY = (page - 1) * currentClientHeightValue
recordEpubJump(
SharedReaderLocator(
chapterIndex = currentChapterIndex,
cfi = "android-scroll:$scrollY"
)
)
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
} else {
sliderCurrentPage = page.toFloat()
val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
paginatedJumpLocatorForPage(
pageIndex = page - 1,
targetLocator = targetLocator,
allowPageFallback = true
)?.let { recordEpubJump(it) }
scrollPaginatedToJumpPage(page - 1, targetLocator)
}
},
onJumpToPage = { page ->
scope.launch {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
sliderCurrentPage = page.toFloat()
val scrollY = (page - 1) * currentClientHeightValue
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
} else {
sliderCurrentPage = page.toFloat()
val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
scrollPaginatedToJumpPage(page - 1, targetLocator)
}
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp),
activeColor = epubReaderSliderColors.activeTrackColor,
inactiveColor = epubReaderSliderColors.inactiveTrackColor,
contentColor = epubReaderSliderColors.contentColor,
thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor,
thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor
)
if (epubSliderChromeVisible && isFastScrubbing) {
val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
}
}
)
if (isPageSliderVisible && isFastScrubbing) {
val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
}
if (showTtsSettingsSheet) {
@ -5974,6 +6205,15 @@ fun EpubReaderHost(
onDismiss = { showTtsReplacementsSheet = false },
)
ReaderFileInfoDialogs(
isFileInfoVisible = showFileInfoDialog,
onFileInfoVisibleChange = { showFileInfoDialog = it },
uiState = uiState,
primaryBookId = uiState.selectedBookId ?: stableBookId,
uriString = uiState.selectedEpubUri?.toString(),
viewModel = viewModel
)
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,
@ -5995,6 +6235,14 @@ fun EpubReaderHost(
)
}
if (showBrightnessSheet) {
ReaderBrightnessSheet(
settings = readerBrightnessSettings,
onSettingsChange = updateReaderBrightness,
onDismiss = { showBrightnessSheet = false }
)
}
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,

View file

@ -45,6 +45,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SearchResultsPanel
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -64,8 +65,7 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResul
val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
@ -249,4 +249,4 @@ fun EpubReaderSearchOverlay(
)
}
}
}
}

View file

@ -109,6 +109,13 @@ import com.aryan.reader.data.CustomFontEntity
import java.io.File
import kotlin.math.roundToInt
typealias ReaderFont = com.aryan.reader.shared.ReaderFont
typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign
typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode
typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode
typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition
typealias FormatSettings = com.aryan.reader.shared.FormatSettings
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size"
@ -153,50 +160,45 @@ fun loadTtsPitch(context: Context): Float {
return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
}
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
ORIGINAL("original", "Original", "Original"),
MERRIWEATHER("merriweather", "Merriweather", "Merriweather"),
LATO("lato", "Lato", "Lato"),
LORA("lora", "Lora", "Lora"),
ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"),
LEXEND("lexend", "Lexend", "Lexend")
}
val ReaderTextAlign.iconResId: Int
get() = when (this) {
ReaderTextAlign.DEFAULT,
ReaderTextAlign.LEFT -> R.drawable.format_align_left
ReaderTextAlign.RIGHT -> R.drawable.format_align_right
ReaderTextAlign.JUSTIFY -> R.drawable.format_align_justify
}
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) {
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default),
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left),
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right),
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify)
}
@get:StringRes
val ReaderTextAlign.displayNameRes: Int
get() = when (this) {
ReaderTextAlign.DEFAULT -> R.string.label_default
ReaderTextAlign.LEFT -> R.string.label_left
ReaderTextAlign.RIGHT -> R.string.label_right
ReaderTextAlign.JUSTIFY -> R.string.label_justify
}
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) {
DEFAULT(0, R.string.label_always_show),
SYNC(1, R.string.label_sync_with_menus),
HIDDEN(2, R.string.label_always_hide)
}
@get:StringRes
val SystemUiMode.titleRes: Int
get() = when (this) {
SystemUiMode.DEFAULT -> R.string.label_always_show
SystemUiMode.SYNC -> R.string.label_sync_with_menus
SystemUiMode.HIDDEN -> R.string.label_always_hide
}
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) {
DEFAULT(0, R.string.label_always_show),
SYNC(1, R.string.label_sync_with_menus),
HIDDEN(2, R.string.label_always_hide)
}
@get:StringRes
val PageInfoMode.titleRes: Int
get() = when (this) {
PageInfoMode.DEFAULT -> R.string.label_always_show
PageInfoMode.SYNC -> R.string.label_sync_with_menus
PageInfoMode.HIDDEN -> R.string.label_always_hide
}
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) {
BOTTOM(0, R.string.label_bottom),
TOP(1, R.string.label_top)
}
data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
val verticalMargin: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
)
@get:StringRes
val PageInfoPosition.titleRes: Int
get() = when (this) {
PageInfoPosition.BOTTOM -> R.string.label_bottom
PageInfoPosition.TOP -> R.string.label_top
}
private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"

View file

@ -327,20 +327,27 @@ private fun handleVerticalAutoAdvance(
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 (!nativeChunks.isNullOrEmpty()) {
val resumeIdx = findTtsChunkResumeIndex(
chunks = nativeChunks,
sourceCfi = lastReadCfi,
startOffsetInSource = currentState.startOffsetInSource,
currentText = currentState.currentText,
currentChunkIndexFallback = currentState.currentChunkIndex
)
if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) {
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
val startChunkIndex = resumeIdx + 1
val token = getAuthToken()
ttsController.start(
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
bookId = ttsReplacementBookId,
chapterIndex = currentTtsChapterIndex,
totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
continueSession = true,
ttsMode = currentTtsMode,
playbackSource = "READER",
@ -371,6 +378,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
bookId = ttsReplacementBookId,
chapterIndex = nextIdx,
totalChapters = chapters.size,
continueSession = true,
@ -450,6 +458,7 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
bookId = ttsReplacementBookId,
chapterIndex = chapterToTry,
totalChapters = chapters.size,
continueSession = true,

View file

@ -0,0 +1,105 @@
package com.aryan.reader.epubreader
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.TtsChunk
import kotlin.math.abs
private val TTS_WHITESPACE = Regex("\\s+")
internal fun sameTtsChunkSource(first: String, second: String): Boolean {
if (first.isBlank() || second.isBlank()) return first == second
val firstPath = CfiUtils.getPath(first)
val secondPath = CfiUtils.getPath(second)
return firstPath == secondPath || cfiPathContains(firstPath, secondPath) || cfiPathContains(secondPath, firstPath)
}
internal fun findTtsChunkStartIndex(
chunks: List<TtsChunk>,
target: TtsChunk?
): Int? {
if (target == null) return null
val exactIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
it.startOffsetInSource == target.startOffsetInSource &&
normalizedTtsText(it.text) == normalizedTtsText(target.text)
}
if (exactIndex >= 0) return exactIndex
val sourceAndOffsetIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
target.startOffsetInSource >= it.startOffsetInSource &&
target.startOffsetInSource < it.startOffsetInSource + it.text.length
}
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
val sourceAndTextIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
ttsTextMatches(it.text, target.text)
}
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
val sourceNearestOffsetIndex = chunks
.mapIndexedNotNull { index, chunk ->
if (sameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
index to abs(chunk.startOffsetInSource - target.startOffsetInSource)
} else {
null
}
}
.minByOrNull { it.second }
?.first
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
return findUniqueTextMatch(chunks, target.text)
}
internal fun findTtsChunkResumeIndex(
chunks: List<TtsChunk>,
sourceCfi: String?,
startOffsetInSource: Int,
currentText: String?,
currentChunkIndexFallback: Int
): Int? {
val target = sourceCfi
?.takeIf { it.isNotBlank() }
?.let {
TtsChunk(
text = currentText.orEmpty(),
sourceCfi = it,
startOffsetInSource = startOffsetInSource.coerceAtLeast(0)
)
}
val matchedIndex = findTtsChunkStartIndex(chunks, target)
?: currentText?.let { findUniqueTextMatch(chunks, it) }
if (matchedIndex != null) return matchedIndex
return currentChunkIndexFallback.takeIf { it in chunks.indices }
}
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
val childParts = childPath.split('/').filter { it.isNotEmpty() }
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
}
private fun normalizedTtsText(text: String): String =
text.replace(TTS_WHITESPACE, " ").trim()
private fun ttsTextMatches(first: String, second: String): Boolean {
val firstNormalized = normalizedTtsText(first)
val secondNormalized = normalizedTtsText(second)
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
return firstNormalized == secondNormalized ||
firstNormalized.startsWith(secondNormalized) ||
secondNormalized.startsWith(firstNormalized)
}
private fun findUniqueTextMatch(chunks: List<TtsChunk>, text: String): Int? {
val matches = chunks.mapIndexedNotNull { index, chunk ->
index.takeIf { ttsTextMatches(chunk.text, text) }
}
return matches.singleOrNull()
}

View file

@ -38,6 +38,20 @@ import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
internal fun readWebViewHitTestTypeOrNull(hitTestTypeProvider: () -> Int?): Int? {
return try {
hitTestTypeProvider()
} catch (e: NullPointerException) {
Timber.w(e, "WebView hit test state was unavailable for tap.")
null
}
}
internal fun isWebViewAnchorHitTestType(type: Int?): Boolean {
return type == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
}
@SuppressLint("ViewConstructor")
class InteractiveWebView(
context: Context,
@ -376,10 +390,11 @@ class InteractiveWebView(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed")
val hitTestResult = this@InteractiveWebView.hitTestResult
val type = hitTestResult.type
val type = readWebViewHitTestTypeOrNull {
this@InteractiveWebView.hitTestResult?.type
}
if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
if (isWebViewAnchorHitTestType(type)) {
Timber.d("Tap was on a link. Consuming tap, not toggling app bars.")
return true
}