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:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -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();"
|
||||
|
|
|
|||
|
|
@ -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) { "" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue