Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,965 @@
// ChapterWebView.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import timber.log.Timber
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
import com.aryan.reader.countWords
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
private fun getFontCssInjection(): String {
return """
@font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); }
@font-face { font-family: 'Lato'; src: url('file:///android_asset/fonts/lato.ttf'); }
@font-face { font-family: 'Lora'; src: url('file:///android_asset/fonts/lora.ttf'); }
@font-face { font-family: 'Roboto Mono'; src: url('file:///android_asset/fonts/roboto_mono.ttf'); }
@font-face { font-family: 'Lexend'; src: url('file:///android_asset/fonts/lexend.ttf'); }
""".trimIndent()
}
private fun getJsToInject(context: Context): String {
return try {
context.assets.open("epub_reader.js").use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
}
} catch (e: Exception) {
Timber.e(e, "Error reading epub_reader.js from assets")
"" // Return empty string on error
}
}
@Suppress("unused")
class AutoScrollJsBridge(
private val callback: () -> Unit
) {
@JavascriptInterface
fun onChapterEnd() {
Timber.d("Bridge: onChapterEnd called from JavaScript. Invoking callback.")
callback()
}
}
@Suppress("unused") // function used by JavaScript
class TtsJsBridge(
private val scope: CoroutineScope,
private val ttsStructuredTextHandler: suspend (String) -> Unit
) {
@JavascriptInterface
fun onStructuredTextExtracted(json: String) {
if (json.isNotBlank() && json != "[]") {
scope.launch {
ttsStructuredTextHandler(json)
}
} else {
scope.launch {
ttsStructuredTextHandler("[]")
}
}
}
}
@Suppress("unused")
class HighlightJsBridge(
private val onCreateCallback: (String, String, String) -> Unit, // Renamed to avoid recursion
private val onClickCallback: ((String, String, Int, Int, Int, Int) -> Unit)? = null // Renamed
) {
@JavascriptInterface
fun onHighlightCreated(cfi: String, text: String, colorId: String) {
onCreateCallback(cfi, text, colorId) // Calls the lambda property
}
@JavascriptInterface
fun onHighlightClicked(cfi: String, text: String, left: Int, top: Int, right: Int, bottom: Int) {
onClickCallback?.invoke(cfi, text, left, top, right, bottom)
}
}
@Suppress("unused")
class ContentBridge(
private val onChunkRequested: (index: Int) -> Unit
) {
@JavascriptInterface
fun requestChunk(index: Int) {
onChunkRequested(index)
}
}
@Suppress("unused")
class CfiJsBridge(
private val onCfiReady: (String) -> Unit,
private val onCfiForBookmarkReady: (String) -> Unit
) {
@JavascriptInterface
fun onCfiExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI and diagnostics
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi", "/4")
val logArray = json.optJSONArray("log")
Timber.d("--- Start CFI Save Diagnostics ---")
Timber.d("Received CFI for saving: $cfi")
if (logArray != null) {
for (i in 0 until logArray.length()) {
Timber.d(logArray.getString(i))
}
} else {
Timber.d("No log array received. Raw response: $jsonResponse")
}
Timber.d("--- End CFI Save Diagnostics ---")
if (cfi.isNotBlank()) {
onCfiReady(cfi)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
// Still call back with a fallback CFI so the app doesn't hang
onCfiReady("/4")
}
}
@JavascriptInterface
fun onCfiForBookmarkExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI for a bookmark action
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi")
val logArray = json.optJSONArray("log")
Timber.d("--- Start CFI Diagnostics (Bookmark) ---")
Timber.d("Received CFI for bookmark: $cfi")
if (logArray != null) {
for (i in 0 until logArray.length()) {
Timber.d(logArray.getString(i))
}
} else {
Timber.d("No log array received. Raw response: $jsonResponse")
}
Timber.d("--- End CFI Diagnostics (Bookmark) ---")
if (cfi != null) {
onCfiForBookmarkReady(cfi)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse")
}
}
}
@Suppress("unused")
class SnippetJsBridge(
private val onSnippetReady: (String, String) -> Unit
) {
@JavascriptInterface
fun onSnippetExtracted(cfi: String, snippet: String) {
Timber.d("SnippetJsBridge.onSnippetExtracted received. CFI: '$cfi', Snippet: '$snippet'")
onSnippetReady(cfi, snippet)
}
}
@Suppress("unused")
class ProgressJsBridge(
private val onTopChunkUpdated: (Int) -> Unit
) {
private var lastReportedChunk = -1
@JavascriptInterface
fun updateTopChunk(chunkIndex: Int) {
if (chunkIndex != lastReportedChunk) {
lastReportedChunk = chunkIndex
onTopChunkUpdated(chunkIndex)
}
}
}
private data class CustomMenuState(
val selectedText: String,
val selectionBounds: Rect,
val finishActionModeCallback: () -> Unit,
val cfi: String? = null,
val isExistingHighlight: Boolean = false
)
@Suppress("unused")
class AiJsBridge(
private val scope: CoroutineScope,
private val onContentReady: suspend (String) -> Unit
) {
@JavascriptInterface
fun onContentExtractedForSummarization(text: String) {
Timber.d("Content extracted for summarization, length: ${text.length}")
if (text.isNotBlank()) {
scope.launch {
onContentReady(text)
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ChapterWebView(
key: Any,
initialHtmlContent: String,
baseUrl: String,
totalChunks: Int,
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
onChunkRequested: (Int) -> Unit,
chapterTitle: String,
isDarkTheme: Boolean,
initialScrollTarget: ChapterScrollPosition?,
initialPageScrollY: Int?,
initialCfi: String?,
initialChunkIndex: Int,
onTopChunkUpdated: (Int) -> Unit,
currentFontSize: Float,
currentLineHeight: Float,
onChapterInitiallyScrolled: () -> Unit,
onTap: () -> Unit,
onPotentialScroll: () -> Unit,
onOverScrollTop: (dragAmount: Float) -> Unit,
onOverScrollBottom: (dragAmount: Float) -> Unit,
onReleaseOverScrollTop: () -> Unit,
onReleaseOverScrollBottom: () -> Unit,
onScrollStateUpdate: (scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) -> Unit,
onWebViewInstanceCreated: (WebView) -> Unit,
onCfiGenerated: (cfi: String) -> Unit,
onBookmarkCfiGenerated: (cfi: String) -> Unit,
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
ttsScope: CoroutineScope,
tocFragments: List<String>,
modifier: Modifier = Modifier,
initialFragmentId: String? = null,
onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean,
isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {},
) {
Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
)
var showExternalLinkDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val density = LocalDensity.current
var localWebViewRef by remember { mutableStateOf<WebView?>(null) }
var customMenuState by remember { mutableStateOf<CustomMenuState?>(null) }
val jsToInject = remember(context) { getJsToInject(context) }
LaunchedEffect(currentFontSize, currentLineHeight) {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
}
val highlightsJson = remember(userHighlights) {
val jsonArray = org.json.JSONArray()
userHighlights.forEach { h ->
val obj = JSONObject()
obj.put("cfi", h.cfi)
obj.put("text", h.text)
obj.put("cssClass", h.color.cssClass)
jsonArray.put(obj)
}
jsonArray.toString()
}
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
AlertDialog(
onDismissRequest = { showExternalLinkDialog = null },
title = { Text("External Link") },
text = { Text("You clicked on an external link:\n\n$urlToShow\n\nWhat would you like to do?") },
confirmButton = {
Row(horizontalArrangement = Arrangement.End) {
TextButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri())
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e, "No activity found to handle intent for URL: $urlToShow")
Toast.makeText(
context,
"No browser found to open the link.",
Toast.LENGTH_LONG
).show()
}
showExternalLinkDialog = null
}) { Text("Open") }
TextButton(onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Link", urlToShow)
clipboard.setPrimaryClip(clip)
showExternalLinkDialog = null
}) { Text("Copy") }
}
},
dismissButton = {
TextButton(onClick = { showExternalLinkDialog = null }) { Text("Cancel") }
}
)
}
Box(modifier = modifier.fillMaxSize()) {
key(
key,
isDarkTheme,
currentFontSize,
currentLineHeight,
currentFontFamily,
currentTextAlign
) {
AndroidView(
factory = { ctx ->
Timber.d(
"InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget"
)
val webView = InteractiveWebView(
context = ctx,
onSingleTap = onTap,
onPotentialScroll = onPotentialScroll,
onOverScrollTop = onOverScrollTop,
onOverScrollBottom = onOverScrollBottom,
onReleaseOverScrollTop = onReleaseOverScrollTop,
onReleaseOverScrollBottom = onReleaseOverScrollBottom,
onShowCustomSelectionMenu = { text, bounds, finishCallback ->
if (text.isNotBlank() && !bounds.isEmpty) {
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = Rect(bounds),
finishActionModeCallback = finishCallback,
isExistingHighlight = false
)
} else {
customMenuState = null
finishCallback()
}
},
onHideCustomSelectionMenu = {
if (customMenuState?.isExistingHighlight != true) {
customMenuState = null
}
}
).apply {
localWebViewRef = this
onWebViewInstanceCreated(this)
addJavascriptInterface(
PageInfoBridge(onScrollStateUpdate),
"PageInfoReporter"
)
addJavascriptInterface(
ProgressJsBridge(onTopChunkUpdated),
"ProgressReporter"
)
addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge")
addJavascriptInterface(HighlightJsBridge(
onCreateCallback = onHighlightCreated,
onClickCallback = { cfi, text, left, top, right, bottom ->
onHighlightClicked()
val densityValue = density.density
val locationOnScreen = IntArray(2)
this.getLocationOnScreen(locationOnScreen)
val xOffset = locationOnScreen[0]
val yOffset = locationOnScreen[1]
val rect = Rect(
(left * densityValue).toInt() + xOffset,
(top * densityValue).toInt() + yOffset,
(right * densityValue).toInt() + xOffset,
(bottom * densityValue).toInt() + yOffset
)
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = rect,
finishActionModeCallback = {
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
},
cfi = cfi,
isExistingHighlight = true
)
}
), "HighlightBridge")
addJavascriptInterface(
AutoScrollJsBridge {
onAutoScrollChapterEnd()
},
"AutoScrollBridge"
)
webChromeClient = object : android.webkit.WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("CFI_DIAGNOSIS:") -> {
Timber.d(
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
)
}
message.startsWith("ImageDiagnosis") -> {
Timber.d("JS -> $message")
}
message.startsWith("TTS_HIGHLIGHT_DIAGNOSIS:") -> {
Timber.d(
"JS -> ${message.substringAfter("TTS_HIGHLIGHT_DIAGNOSIS: ")}"
)
}
message.startsWith("HIGHLIGHT_DEBUG:") -> {
Timber.d(
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"
)
}
message.startsWith("ReaderFontDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
)
}
message.startsWith("AutoScrollDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}"
)
}
message.startsWith("FRAG_NAV_DEBUG") -> {
Timber.tag("FRAG_NAV_DEBUG").d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}")
}
else -> {
Timber.d(
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
)
}
}
}
return true
}
}
addJavascriptInterface(
CfiJsBridge(
onCfiReady = { cfi -> onCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
), "CfiBridge")
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
onSnippetForBookmarkReady(
cfi,
snippet
)
}, "SnippetBridge")
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
addJavascriptInterface(
AiJsBridge(ttsScope, onContentReadyForSummarization),
"AiBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
Timber.d("Intercepted external link: $url")
showExternalLinkDialog = url
return true
}
return false
}
override fun onLoadResource(view: WebView?, url: String?) {
super.onLoadResource(view, url)
if (url?.contains(".jpg", true) == true ||
url?.contains(".jpeg", true) == true ||
url?.contains(".png", true) == true ||
url?.contains(".gif", true) == true ||
url?.contains(".svg", true) == true ||
url?.contains("image", true) == true
) {
Timber.d(
"WebView is attempting to load resource: $url"
)
}
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Timber.d(
"onPageFinished. Injecting CSS and Font: ${currentFontFamily.fontFamilyName}"
)
view?.evaluateJavascript(jsToInject, null)
view?.evaluateJavascript(
"javascript:window.applyReaderTheme($isDarkTheme);",
null
)
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
Timber.tag("FRAG_NAV_DEBUG").d("onPageFinished: Re-injecting TOC_FRAGMENTS: $fragmentsJson")
view?.evaluateJavascript("javascript:window.TOC_FRAGMENTS = $fragmentsJson;", null)
view?.evaluateJavascript("javascript:setTimeout(window.auditTocFragments, 500);", null)
view?.evaluateJavascript("javascript:window.HighlightBridgeHelper.restoreHighlights('${escapeJsString(highlightsJson)}');", null)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val injectFontJs =
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it")
}
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
} else {
currentFontFamily.fontFamilyName
}
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');",
null
)
view?.evaluateJavascript(
"javascript:window.checkImagesForDiagnosis();",
null
)
view?.evaluateJavascript(
"javascript:window.virtualization.init($initialChunkIndex, $totalChunks);",
null
)
@Suppress("VariableNeverRead") var scrollActionTaken = false
if (!initialCfi.isNullOrBlank()) {
val cfiJsCommand =
"javascript:window.scrollToCfi('$initialCfi');"
Timber.d(
"WebView onPageFinished: Executing initial scroll to CFI: $initialCfi"
)
view?.evaluateJavascript(cfiJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (!initialFragmentId.isNullOrBlank()) {
Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null
)
onChapterInitiallyScrolled()
scrollActionTaken = true
} else if (initialScrollTarget != null) {
val scrollJsCommand = when (initialScrollTarget) {
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
else -> "javascript:window.scrollToChapterStart();"
}
Timber.d(
"WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget"
)
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (initialPageScrollY != null && initialPageScrollY > 0) {
val scrollJsCommand =
"javascript:window.scrollToSpecificY($initialPageScrollY);"
Timber.d(
"WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY"
)
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else {
Timber.d(
"WebView onPageFinished: No specific scroll, defaulting to start."
)
view?.evaluateJavascript("javascript:window.scrollToChapterStart();") {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
}
view?.clearFocus()
view?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
}
}
settings.apply {
javaScriptEnabled = true
allowFileAccess = true
allowContentAccess = true
domStorageEnabled = true
layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL
setNeedInitialFocus(false)
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
useWideViewPort = true
loadWithOverviewMode = true
}
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
this.setBackgroundColor(Color.TRANSPARENT)
Timber.d(
"WebView loading initial data with base URL: $baseUrl (Key: $key)"
)
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
}
webView
},
update = { webView ->
Timber.d(
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
)
localWebViewRef = webView
onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
} else {
currentFontFamily.fontFamilyName
}
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
webView.evaluateJavascript("javascript:window.setTocFragments($fragmentsJson);", null)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');",
null
)
},
modifier = Modifier.fillMaxSize()
)
}
// Custom Selection Menu Popup
customMenuState?.let { state ->
val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val topMargin = with(density) { 16.dp.toPx() }.toInt()
val bottomMargin = with(density) {
if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx()
}.toInt()
var x = state.selectionBounds.centerX() - popupContentSize.width / 2
var y = state.selectionBounds.top - popupContentSize.height - topMargin
if (y < with(density) { 24.dp.toPx() }.toInt()) {
y = state.selectionBounds.bottom + bottomMargin
}
if (x < 0) x = 0
if (x + popupContentSize.width > windowSize.width) {
x = windowSize.width - popupContentSize.width
}
if (y + popupContentSize.height > windowSize.height) {
y = windowSize.height - popupContentSize.height
}
if (y < 0) y = 0
return IntOffset(
x.coerceIn(0, windowSize.width - popupContentSize.width),
y.coerceIn(0, windowSize.height - popupContentSize.height)
)
}
}
}
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = {
state.finishActionModeCallback()
customMenuState = null
}
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = Modifier.width(IntrinsicSize.Max)
) {
// 1. Color Row (Improved sizing and gaps)
Row(
modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center, // Centered colors
verticalAlignment = Alignment.CenterVertically
) {
HighlightColor.entries.forEach { colorEnum ->
Box(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(24.dp)
.background(colorEnum.color, CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
.clickable {
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
if (state.isExistingHighlight && state.cfi != null) {
// UPDATE EXISTING HIGHLIGHT
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
null
)
} else {
// CREATE NEW HIGHLIGHT
Timber.d("Kotlin: Requesting CREATE via JS")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
null
)
}
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
customMenuState = null
}
)
}
}
// 2. Delete Option (Only for existing highlights)
if (state.isExistingHighlight && state.cfi != null) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
// LOGGING START
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
// 1. IMPROVED LOOKUP: Check if the clicked CFI exists within any split CFI string
val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
}
if (highlightToDelete == null) {
Timber.e("Kotlin: ERROR - Lookup failed. CFI '${state.cfi}' not found in any highlight.")
} else {
Timber.d("Kotlin: SUCCESS - Found highlight object. Full CFI: '${highlightToDelete.cfi}', Color: ${highlightToDelete.color.id}")
val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi ->
Timber.d("Kotlin: Requesting JS removal for part: '$partCfi'")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
null
)
}
onHighlightDeleted(highlightToDelete.cfi)
}
state.finishActionModeCallback()
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Text(
text = "Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
// 2. Copy Option
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
clipboard.setPrimaryClip(clip)
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.CopyAll,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Copy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 3. Dictionary Option (Preserving Logic)
if (!isOss && state.selectedText.length <= 2000) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
val wordCount = countWords(textToDefine)
if (isProUser || wordCount <= 1) {
onWordSelectedForAiDefinition(textToDefine)
} else {
onShowDictionaryUpsellDialog()
}
}
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Dictionary",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,304 @@
// EpubReaderAi.kt
package com.aryan.reader.epubreader
import timber.log.Timber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.R
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.summarizationUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.jsoup.Jsoup
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Handles the raw network streaming for book content summarization.
*/
suspend fun summarizeBookContent(
content: String,
onUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
if (content.isBlank()) {
onError("The book content is empty.")
onFinish()
return
}
Timber.d("Starting summarization for content of length: ${content.length}")
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(summarizationUrl)
connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 15000
connection.readTimeout = 120000
connection.doOutput = true
connection.doInput = true
val jsonPayload = JSONObject().apply {
put("content_type", "text")
put("data", content)
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
Timber.d("Summarization: Got response code $responseCode")
if (responseCode == HttpURLConnection.HTTP_OK) {
var hasReceivedData = false
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
Timber.d("Summarization: Received line: $line")
try {
val jsonResponse = JSONObject(line!!)
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
onUpdate(it)
hasReceivedData = true
}
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
onError(it)
}
} catch (e: Exception) {
Timber.w(e, "Could not parse stream line: $line")
}
}
}
if (!hasReceivedData) {
onError("Failed to parse summary from server response.")
}
} else {
val errorBody = try {
connection.errorStream?.bufferedReader()?.use { it.readText() }
} catch (_: Exception) { null }
val errorDetail = try {
JSONObject(errorBody.toString()).getString("detail")
} catch (_: Exception) { "Could not fetch summary." }
onError("Error: $responseCode. $errorDetail")
}
} catch (e: Exception) {
Timber.e(e, "Network error during summarization: ${e.message}")
onError("Network error. Please check connection and server status.")
} finally {
connection?.disconnect()
onFinish()
}
}
}
/**
* Orchestrates the logic for generating a Story Recap.
* Fetches past summaries from cache/network and combines with current context.
*/
suspend fun executeRecapLogic(
epubBook: EpubBook,
chapterIndex: Int,
characterLimit: Int,
summaryCacheManager: SummaryCacheManager,
paginator: IPaginator?,
onProgressUpdate: (String) -> Unit,
onResultUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
Timber.d("executeRecapLogic called. ChapterIndex: $chapterIndex, CharLimit: $characterLimit")
val pastSummaries = mutableListOf<String>()
val chapters = epubBook.chapters
// 1. Fetch Past Summaries
for (i in 0 until chapterIndex) {
onProgressUpdate("Analyzing Chapter ${i + 1}...")
val cached = summaryCacheManager.getSummary(epubBook.title, i)
if (cached != null) {
pastSummaries.add(cached)
} else {
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")
doc.body().text()
} catch (_: Exception) { "" }
}
if (textToSummarize.length > 100) {
val sb = StringBuilder()
val latch = kotlinx.coroutines.CompletableDeferred<Boolean>()
summarizeBookContent(
content = textToSummarize,
onUpdate = { sb.append(it) },
onError = {
Timber.e("Failed to summarize Ch $i for recap: $it")
latch.complete(false)
},
onFinish = { latch.complete(true) }
)
val success = latch.await()
if (success && sb.isNotEmpty()) {
val summary = sb.toString()
summaryCacheManager.saveSummary(epubBook.title, i, summary)
pastSummaries.add(summary)
}
}
}
// Small delay to prevent rate limits
if (pastSummaries.isNotEmpty() && !summaryCacheManager.hasSummary(epubBook.title, i)) {
delay(500)
}
}
// 2. Get Current Context
onProgressUpdate("Reading current position...")
val currentChapterText = paginator?.getPlainTextForChapter(chapterIndex)
?: withContext(Dispatchers.IO) {
try {
Jsoup.parse(File("${epubBook.extractionBasePath}/${chapters[chapterIndex].htmlFilePath}"), "UTF-8").body().text()
} catch (_: Exception) { "" }
}
val endIndex = characterLimit.coerceIn(0, currentChapterText.length)
val textSoFar = currentChapterText.substring(0, endIndex)
// Fallback if text is blank
val finalContextText = if (textSoFar.isBlank() && currentChapterText.isNotEmpty()) {
currentChapterText.take(500)
} else {
textSoFar
}
onProgressUpdate("Generating Recap...")
fetchRecap(
pastSummaries = pastSummaries,
currentText = finalContextText,
onUpdate = { chunk -> onResultUpdate(chunk) },
onError = { error -> onError(error) },
onFinish = { onFinish() }
)
}
/**
* Container for all AI-related popups and dialogs (Summary, Recap, Definition, Upsells).
*/
@Composable
fun EpubReaderAiOverlays(
// Summarization State
showSummarizationPopup: Boolean,
summarizationResult: SummarizationResult?,
isSummarizationLoading: Boolean,
onDismissSummarization: () -> Unit,
showSummarizationUpsellDialog: Boolean,
onDismissSummarizationUpsell: () -> Unit,
// Recap State
showRecapPopup: Boolean,
recapResult: SummarizationResult?,
isRecapLoading: Boolean,
onDismissRecap: () -> Unit,
// Dictionary State
showAiDefinitionPopup: Boolean,
selectedTextForAi: String?,
aiDefinitionResult: AiDefinitionResult?,
isAiDefinitionLoading: Boolean,
onDismissAiDefinition: () -> Unit,
showDictionaryUpsellDialog: Boolean,
onDismissDictionaryUpsell: () -> Unit,
// Navigation
onNavigateToPro: () -> Unit,
isTtsSessionActive: Boolean
) {
if (showSummarizationPopup) {
SummarizationPopup(
title = "Chapter Summary",
result = summarizationResult,
isLoading = isSummarizationLoading,
onDismiss = onDismissSummarization,
isMainTtsActive = isTtsSessionActive
)
}
if (showRecapPopup) {
SummarizationPopup(
title = "Story Recap (Beta)",
result = recapResult,
isLoading = isRecapLoading,
onDismiss = onDismissRecap,
isMainTtsActive = isTtsSessionActive,
)
}
if (showSummarizationUpsellDialog) {
AlertDialog(
onDismissRequest = onDismissSummarizationUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.summarize), contentDescription = null) },
title = { Text("Unlock Chapter Summarization") },
text = { Text("Get concise summaries of any chapter with Episteme Pro. Upgrade to start using this feature.") },
confirmButton = {
TextButton(onClick = {
onDismissSummarizationUpsell()
onNavigateToPro()
}) { Text("Learn More") }
},
dismissButton = {
TextButton(onClick = onDismissSummarizationUpsell) { Text("Not Now") }
}
)
}
if (showAiDefinitionPopup) {
AiDefinitionPopup(
word = selectedTextForAi,
result = aiDefinitionResult,
isLoading = isAiDefinitionLoading,
onDismiss = onDismissAiDefinition,
isMainTtsActive = isTtsSessionActive
)
}
if (showDictionaryUpsellDialog) {
AlertDialog(
onDismissRequest = onDismissDictionaryUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.ai), contentDescription = null) },
title = { Text("Unlock Smart Dictionary") },
text = { Text("Defining entire phrases and paragraphs up to 2000 characters is a Pro feature. Upgrade to get instant definitions for any selected text.") },
confirmButton = {
TextButton(onClick = {
onDismissDictionaryUpsell()
onNavigateToPro()
}) { Text("Learn More") }
},
dismissButton = {
TextButton(onClick = onDismissDictionaryUpsell) { Text("Not Now") }
}
)
}
}

View file

@ -0,0 +1,268 @@
package com.aryan.reader.epubreader
import android.content.Context
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import kotlin.math.min
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
data class Bookmark(
val cfi: String,
val chapterTitle: String,
val label: String? = null,
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red")
}
data class UserHighlight(
val id: String = UUID.randomUUID().toString(),
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int
)
fun escapeJsString(value: String): String {
return value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
}
// --- Persistence Helpers ---
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from ViewModel")
emptyList()
}
} else {
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
prefs.getStringSet(key, emptySet()) ?: emptySet()
}
return stringSetToParse.mapNotNull { jsonString ->
try {
val json = JSONObject(jsonString)
val chapterIndex = if (json.has("chapterIndex")) {
json.getInt("chapterIndex")
} else {
val chapterTitle = json.getString("chapterTitle")
chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0)
}
Bookmark(
cfi = json.getString("cfi"),
chapterTitle = json.getString("chapterTitle"),
label = if (json.has("label")) json.getString("label") else null,
snippet = json.getString("snippet"),
pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null,
totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null,
chapterIndex = chapterIndex
)
} catch (_: Exception) {
null
}
}.toSet()
}
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
}
jsonArray.put(obj)
}
prefs.edit { putString(key, jsonArray.toString()) }
}
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonString = prefs.getString(key, "[]") ?: "[]"
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex")
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error loading highlights")
}
return list
}
// --- Logic Helpers ---
fun processAndAddHighlight(
newCfi: String,
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
currentList: MutableList<UserHighlight>
) {
val newParts = newCfi.split('|')
val newStartFull = newParts.first()
val newEndFull = newParts.last()
val newStartPath = newStartFull.split(':').first()
val newStartOffset = newStartFull.substringAfter(':', "0").toInt()
val newEndPath = newEndFull.split(':').first()
val newEndOffset = newEndFull.substringAfter(':', "0").toInt()
val iterator = currentList.iterator()
var finalStartPath = newStartPath
var finalStartOffset = newStartOffset
var finalEndPath = newEndPath
var finalEndOffset = newEndOffset
var finalText = newText
while (iterator.hasNext()) {
val existing = iterator.next()
if (existing.chapterIndex != chapterIndex || existing.color != newColor) continue
val exParts = existing.cfi.split('|')
val exStartFull = exParts.first()
val exEndFull = exParts.last()
val exStartPath = exStartFull.split(':').first()
val exStartOffset = exStartFull.substringAfter(':', "0").toInt()
val exEndPath = exEndFull.split(':').first()
val exEndOffset = exEndFull.substringAfter(':', "0").toInt()
fun comparePaths(p1: String, p2: String): Int {
val parts1 = p1.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
val parts2 = p2.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
val len = min(parts1.size, parts2.size)
for (i in 0 until len) {
if (parts1[i] != parts2[i]) return parts1[i] - parts2[i]
}
return parts1.size - parts2.size
}
val startCmp = comparePaths(exStartPath, newEndPath)
val endCmp = comparePaths(exEndPath, newStartPath)
val isDisjoint = (startCmp > 0) || (startCmp == 0 && exStartOffset > newEndOffset) ||
(endCmp < 0) || (endCmp == 0 && exEndOffset < newStartOffset)
if (!isDisjoint) {
iterator.remove()
val unionStartCmp = comparePaths(finalStartPath, exStartPath)
if (unionStartCmp > 0 || (unionStartCmp == 0 && finalStartOffset > exStartOffset)) {
finalStartPath = exStartPath
finalStartOffset = exStartOffset
}
val unionEndCmp = comparePaths(finalEndPath, exEndPath)
if (unionEndCmp < 0 || (unionEndCmp == 0 && finalEndOffset < exEndOffset)) {
finalEndPath = exEndPath
finalEndOffset = exEndOffset
}
if (existing.text.length > finalText.length) finalText = existing.text
}
}
currentList.add(UserHighlight(
cfi = "$finalStartPath:$finalStartOffset|$finalEndPath:$finalEndOffset",
text = finalText,
color = newColor,
chapterIndex = chapterIndex
))
}
// --- UI Components ---
@Composable
fun BookmarkButton(
isBookmarked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.width(48.dp)
.height(48.dp)
.clip(RectangleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
),
contentAlignment = Alignment.TopCenter
) {
AnimatedVisibility(
visible = isBookmarked,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
painter = painterResource(id = R.drawable.bookmark),
contentDescription = "Bookmark",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}

View file

@ -0,0 +1,96 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.LocatorConverter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
data class ChapterLoadingResult(
val head: String,
val chunks: List<String>,
val startChunkIndex: Int,
val isSuccess: Boolean,
val errorMessage: String? = null
)
/**
* loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc).
*/
suspend fun loadChapterContent(
epubBook: EpubBook,
chapterIndex: Int,
chunkTargetOverride: Int?,
isInitialCfiLoad: Boolean,
cfiToLoad: String?,
locatorConverter: LocatorConverter
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter = epubBook.chapters.getOrNull(chapterIndex)
if (chapter == null) {
return@withContext ChapterLoadingResult("", emptyList(), 0, false, "Chapter index out of bounds")
}
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
val bodyChildren = doc.body().children().toList()
// Split into chunks of 20 elements
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
}
// Fallback for empty chapters
if (chunkedList.isEmpty()) {
head to listOf("<body><p>This chapter is empty.</p></body>")
} else {
head to chunkedList
}
} else {
"" to listOf("<h1>Chapter not found</h1>")
}
var targetChunk = 0
if (chunkTargetOverride != null) {
Timber.d("Applying chunk target override: $chunkTargetOverride")
targetChunk = chunkTargetOverride
}
else if (isInitialCfiLoad && cfiToLoad != null) {
Timber.d("Calculating target chunk for initial CFI: $cfiToLoad")
val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfiToLoad)
val calculatedChunk = locator?.let { it.blockIndex / 20 }
if (calculatedChunk != null) {
targetChunk = calculatedChunk
} else {
Timber.w("Could not determine target chunk for CFI. Loading all (fallback to last).")
targetChunk = if (chunks.isNotEmpty()) chunks.size - 1 else 0
}
}
targetChunk = targetChunk.coerceIn(0, maxOf(0, chunks.size - 1))
ChapterLoadingResult(
head = headContent,
chunks = chunks,
startChunkIndex = targetChunk,
isSuccess = true
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse chapter")
ChapterLoadingResult(
head = "",
chunks = listOf("<h1>Error loading chapter</h1><p>${e.message}</p>"),
startChunkIndex = 0,
isSuccess = false,
errorMessage = e.message
)
}
}

View file

@ -0,0 +1,781 @@
// EpubReaderControls.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
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.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
searchState: SearchState,
bookTitle: String,
currentRenderMode: RenderMode,
isBookmarked: Boolean,
isTtsActive: Boolean,
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
onNavigateBack: () -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = modifier
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (searchState.isSearchActive) {
SearchTopBar(
searchState = searchState,
focusRequester = searchFocusRequester,
onCloseSearch = onCloseSearch
)
} else {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Spacer(Modifier.width(8.dp))
Text(
text = bookTitle.take(40) + if (bookTitle.length > 40) "..." else "",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box {
var showMoreMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMoreMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text("Reading Mode: Vertical") },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = "Selected") }
)
DropdownMenuItem(
text = { Text("Reading Mode: Paginated") },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = "Selected") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(if (isBookmarked) "Remove bookmark" else "Bookmark this page") },
onClick = {
showMoreMenu = false
onToggleBookmark()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Tap to Turn Pages") },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onToggleTapToNavigate(!tapToNavigateEnabled)
showMoreMenu = false
},
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Volume Button Scrolling") },
enabled = currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false
},
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Auto Scroll") },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
showMoreMenu = false
onStartAutoScroll()
}
)
}
}
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun EpubReaderBottomBar(
isVisible: Boolean,
currentRenderMode: RenderMode,
isTtsSessionActive: Boolean,
ttsState: TtsState,
isProUser: Boolean,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onSummarize: () -> Unit,
onRecap: () -> Unit,
onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth().height(45.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
IconButton(
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider")
}
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = Icons.Default.Menu, contentDescription = "Chapters Menu")
}
IconButton(onClick = onToggleFormat) {
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = "Text Formatting")
}
IconButton(onClick = onToggleSearch) {
Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
}
@Suppress("KotlinConstantConditions")
if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showAiFeaturesMenu = true }) {
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }
) {
DropdownMenuItem(
text = { Text("Chapter Summarization") },
onClick = {
showAiFeaturesMenu = false
onSummarize()
}
)
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem(
text = { Text("Recap (Beta)") },
onClick = {
showAiFeaturesMenu = false
onRecap()
}
)
}
}
}
}
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onToggleTts) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
)
}
if (isTtsSessionActive) {
IconButton(
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) "Pause TTS" else "Resume TTS"
)
}
}
}
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
startPageThumbnail: Bitmap?,
paginator: IPaginator?,
chapters: List<EpubChapter>,
onClose: () -> Unit,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(),
exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut()
) {
Box(modifier = Modifier.fillMaxSize()) {
// Dismiss area
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClose() }
)
// Fast scrub overlay
// Note: In the refactor, we rely on the parent or this logic to determine "isFastScrubbing".
// Since `isFastScrubbing` was state in the parent, we'll implement a local check or just show it if `isVisible`.
// Ideally, the parent handles the "Scrubbing Animation" separately, but let's bundle it here for simplicity.
// For now, we only show the static overlay logic.
// If we want the big center indicator, we can render it based on interaction state here.
// Top back button
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Exit slider navigation"
)
}
// Bottom controls
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoxWithConstraints(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Slider(
value = sliderCurrentPage,
onValueChange = onScrub,
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
steps = if (totalPages > 2) totalPages - 2 else 0,
modifier = Modifier.fillMaxWidth(),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
},
track = { sliderState ->
val trackHeight = 2.dp
val trackShape = RoundedCornerShape(trackHeight)
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) 0f else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
shape = trackShape
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
color = MaterialTheme.colorScheme.primary,
shape = trackShape
)
)
}
}
)
// Thumbnail Indicator
val startPageOffsetFraction = if (totalPages > 1) {
(sliderStartPage - 1).toFloat() / (totalPages - 1)
} else {
0f
}
val thumbWidth = 20.dp
val trackWidth = maxWidth - thumbWidth
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
val thumbnailModifier = Modifier
.graphicsLayer { clip = false }
.align(Alignment.TopStart)
.offset(
x = startPagePixelPosition - (45.dp / 2),
y = (-72).dp
)
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = "Start page thumbnail",
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
val startPageChapterIndex = remember(sliderStartPage, paginator) {
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
}
val startPageChapterTitle = remember(startPageChapterIndex) {
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
}
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
PaginatedThumbnailContent(
pageNumber = sliderStartPage,
chapterTitle = startPageChapterTitle
)
}
}
}
Text(
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp
)
}
}
}
}
}
// --- Helpers moved from Screen ---
@Composable
fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Icon(
painter = painterResource(id = R.drawable.slider),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
Text(
text = "Page $currentPage of $totalPages",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) {
val borderColor = MaterialTheme.colorScheme.primary
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier
.width(45.dp)
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, borderColor)
) {
content()
}
Box(
modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(borderColor)
)
}
}
@Composable
private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (chapterTitle != null) {
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
lineHeight = 10.sp
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "$pageNumber",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
}
}
}
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
return withContext(Dispatchers.Main) {
if (webView.width <= 0 || webView.height <= 0) return@withContext null
try {
val thumbnailWidth = 180
val thumbnailHeight = 256
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
val canvas = Canvas(bitmap)
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
canvas.scale(scale, scale)
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
webView.draw(canvas)
bitmap
} catch (e: Exception) {
Timber.e(e, "Failed to capture webview content")
null
}
}
}
@Composable
fun AutoScrollControls(
isPlaying: Boolean,
onPlayPauseToggle: () -> Unit,
speed: Float,
onSpeedChange: (Float) -> Unit,
onClose: () -> Unit,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "AutoScrollUnified"
) { collapsed ->
Row(
modifier = Modifier.padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (collapsed) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = "Expand",
tint = MaterialTheme.colorScheme.onSurface
)
}
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
} else {
IconButton(
onClick = onClose,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
IconButton(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(0.1f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Remove, "Slower", modifier = Modifier.size(18.dp))
}
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"),
modifier = Modifier.widthIn(min = 40.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(10f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Add, "Faster", modifier = Modifier.size(18.dp))
}
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
IconButton(
onClick = { onCollapseChange(true) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Collapse",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View file

@ -0,0 +1,731 @@
// EpubReaderDrawer.kt
package com.aryan.reader.epubreader
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastSumBy
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun VerticalScrollbar(
listState: LazyListState,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isDragged by interactionSource.collectIsDraggedAsState()
val scrollbarState by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val visibleItemsInfo = layoutInfo.visibleItemsInfo
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
return@derivedStateOf null
}
val averageItemHeight = visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
val viewportRatio = viewportHeight / estimatedContentHeight
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
val maxScrollPixels = estimatedContentHeight - viewportHeight
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
val trackHeight = viewportHeight - thumbHeight
val thumbOffset = trackHeight * scrollProgress
ScrollbarCalculations(
thumbHeight = thumbHeight,
thumbOffset = thumbOffset,
contentHeight = estimatedContentHeight,
viewportHeight = viewportHeight
)
}
}
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = 200),
label = "ScrollbarAlpha"
)
if (scrollbarState != null) {
val state = scrollbarState!!
val draggableState = rememberDraggableState { delta ->
val trackHeight = state.viewportHeight - state.thumbHeight
if (trackHeight > 0) {
val scrollRatio = delta / trackHeight
val totalScrollableDistance = state.contentHeight - state.viewportHeight
val scrollDelta = scrollRatio * totalScrollableDistance
listState.dispatchRawDelta(scrollDelta)
}
}
Box(
modifier = modifier
.width(30.dp)
.fillMaxHeight()
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
interactionSource = interactionSource
)
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.graphicsLayer {
translationY = state.thumbOffset
}
.padding(end = 4.dp)
.width(6.dp)
.height(with(androidx.compose.ui.platform.LocalDensity.current) { state.thumbHeight.toDp() })
.alpha(alpha)
.background(
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(100)
)
)
}
}
}
private data class ScrollbarCalculations(
val thumbHeight: Float,
val thumbOffset: Float,
val contentHeight: Float,
val viewportHeight: Float
)
@Composable
fun EpubReaderDrawerSheet(
chapters: List<EpubChapter>,
tableOfContents: List<EpubTocEntry>,
activeFragmentId: String?,
bookmarks: Set<Bookmark>,
userHighlights: List<UserHighlight>,
currentChapterIndex: Int,
currentChapterInPaginatedMode: Int?,
renderMode: RenderMode,
onNavigateToChapter: (Int) -> Unit,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToBookmark: (Bookmark) -> Unit,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit,
onRenameBookmark: (Bookmark, String) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
) {
ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
Tab(
selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
text = { Text("Chapters") }
)
Tab(
selected = drawerPagerState.currentPage == 1,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(1) } },
text = { Text("Bookmarks") }
)
Tab(
selected = drawerPagerState.currentPage == 2,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text("Highlights") }
)
}
HorizontalPager(
state = drawerPagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { page ->
when (page) {
0 -> ChaptersList(
chapters = chapters,
tocEntries = tableOfContents,
currentChapterIndex = currentChapterIndex,
currentChapterInPaginatedMode = currentChapterInPaginatedMode,
renderMode = renderMode,
onNavigateToTocEntry = onNavigateToTocEntry,
onNavigateToChapter = onNavigateToChapter,
activeFragmentId = activeFragmentId
)
1 -> BookmarksList(
bookmarks = bookmarks,
onNavigateToBookmark = onNavigateToBookmark,
onRenameBookmark = onRenameBookmark,
onDeleteBookmark = onDeleteBookmark
)
2 -> HighlightsList(
userHighlights = userHighlights,
chapters = chapters,
onNavigateToHighlight = onNavigateToHighlight,
onDeleteHighlight = onDeleteHighlight
)
}
}
}
}
}
@Composable
private fun ChaptersList(
chapters: List<EpubChapter>,
tocEntries: List<EpubTocEntry>,
currentChapterIndex: Int,
currentChapterInPaginatedMode: Int?,
renderMode: RenderMode,
activeFragmentId: String?,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToChapter: (Int) -> Unit
) {
val listState = rememberLazyListState()
val effectiveToc = remember(tocEntries, chapters) {
tocEntries.ifEmpty {
chapters.map { EpubTocEntry(it.title, it.absPath, null, it.depth) }
}
}
val currentChapterPath = remember(chapters, currentChapterIndex, currentChapterInPaginatedMode, renderMode) {
val idx = when (renderMode) {
RenderMode.PAGINATED -> currentChapterInPaginatedMode ?: -1
RenderMode.VERTICAL_SCROLL -> currentChapterIndex
}
chapters.getOrNull(idx)?.absPath
}
val firstEntryForCurrentChapter = remember(effectiveToc, currentChapterPath) {
val entry = effectiveToc.firstOrNull { it.absolutePath == currentChapterPath }
Timber.tag("FRAG_NAV_DEBUG").d("Computed First Entry for Chapter: '${entry?.label}' (Path: $currentChapterPath)")
entry
}
val allParentIndices = remember(effectiveToc) {
effectiveToc.indices.filter { i ->
val next = effectiveToc.getOrNull(i + 1)
next != null && next.depth > effectiveToc[i].depth
}.toSet()
}
var expandedEntryIndices by rememberSaveable(effectiveToc) {
mutableStateOf(allParentIndices)
}
val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) {
val result = mutableListOf<Pair<Int, EpubTocEntry>>()
val visibilityStack = BooleanArray(50) { false }
visibilityStack[0] = true
for (i in effectiveToc.indices) {
val entry = effectiveToc[i]
val depth = entry.depth.coerceIn(0, 49)
if (visibilityStack[depth]) {
result.add(i to entry)
val isExpanded = expandedEntryIndices.contains(i)
if (depth + 1 < visibilityStack.size) {
visibilityStack[depth + 1] = isExpanded
}
} else {
if (depth + 1 < visibilityStack.size) {
visibilityStack[depth + 1] = false
}
}
}
result
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxHeight().padding(end = 12.dp)
) {
items(
items = visibleItemInfo,
key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" }
) { (originalIndex, entry) ->
val nextItem = effectiveToc.getOrNull(originalIndex + 1)
val hasChildren = nextItem != null && nextItem.depth > entry.depth
val isExpanded = expandedEntryIndices.contains(originalIndex)
// HIGHLIGHT LOGIC FIXED
val isCurrentPath = currentChapterPath == entry.absolutePath
val matchesFragment = entry.fragmentId == activeFragmentId
// Fallback logic
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter
val isHighlighting = isCurrentPath && (matchesFragment || isFallback)
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback")
}
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting")
}
TocTreeItem(
label = entry.label,
depth = entry.depth,
isExpanded = isExpanded,
hasChildren = hasChildren,
isCurrent = isHighlighting,
onToggleExpand = {
expandedEntryIndices = if (isExpanded) {
expandedEntryIndices - originalIndex
} else {
expandedEntryIndices + originalIndex
}
},
onClick = {
if (tocEntries.isEmpty()) {
onNavigateToChapter(originalIndex)
} else {
onNavigateToTocEntry(entry)
}
}
)
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
@Composable
private fun TocTreeItem(
label: String,
depth: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * depth).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(
enabled = hasChildren,
onClick = onToggleExpand
),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (depth == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (depth == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
)
}
}
@Composable
private fun BookmarksList(
bookmarks: Set<Bookmark>,
onNavigateToBookmark: (Bookmark) -> Unit,
onRenameBookmark: (Bookmark, String) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit
) {
if (bookmarks.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any bookmarks yet.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
} else {
var bookmarkMenuExpandedFor by remember { mutableStateOf<Bookmark?>(null) }
var showDeleteConfirmDialogFor by remember { mutableStateOf<Bookmark?>(null) }
var showRenameBookmarkDialog by remember { mutableStateOf<Bookmark?>(null) }
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
) {
items(
items = bookmarks.distinctBy { it.cfi }.sortedBy { it.cfi },
key = { it.cfi }
) { bookmark ->
ListItem(
headlineContent = {
Text(
text = bookmark.label?.takeIf { it.isNotBlank() } ?: bookmark.snippet.ifBlank { "Bookmark" },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge
)
},
supportingContent = {
Column {
Text(
text = bookmark.chapterTitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (bookmark.pageInChapter != null && bookmark.totalPagesInChapter != null) {
Text(
text = "Page ${bookmark.pageInChapter} of ${bookmark.totalPagesInChapter}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
trailingContent = {
Box {
IconButton(onClick = { bookmarkMenuExpandedFor = bookmark }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options for bookmark"
)
}
DropdownMenu(
expanded = bookmarkMenuExpandedFor == bookmark,
onDismissRequest = { bookmarkMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
showRenameBookmarkDialog = bookmark
bookmarkMenuExpandedFor = null
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showDeleteConfirmDialogFor = bookmark
bookmarkMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToBookmark(bookmark) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
showRenameBookmarkDialog?.let { bookmarkToRename ->
var newTitle by remember { mutableStateOf("") }
val currentName = bookmarkToRename.label?.takeIf { it.isNotBlank() } ?: bookmarkToRename.snippet
AlertDialog(
onDismissRequest = { showRenameBookmarkDialog = null },
title = { Text("Rename Bookmark") },
text = {
androidx.compose.material3.OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("New Name") },
placeholder = {
Text(
text = currentName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
if (newTitle.isNotBlank()) {
onRenameBookmark(bookmarkToRename, newTitle)
}
showRenameBookmarkDialog = null
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showRenameBookmarkDialog = null }) {
Text("Cancel")
}
}
)
}
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialogFor = null },
title = { Text("Delete Bookmark?") },
text = { Text("Are you sure you want to permanently delete this bookmark?") },
confirmButton = {
TextButton(
onClick = {
onDeleteBookmark(bookmarkToDelete)
showDeleteConfirmDialogFor = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmDialogFor = null }) {
Text("Cancel")
}
}
)
}
}
}
@Composable
private fun HighlightsList(
userHighlights: List<UserHighlight>,
chapters: List<EpubChapter>,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
) {
if (userHighlights.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text("No highlights yet.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
}
} else {
var highlightMenuExpandedFor by remember { mutableStateOf<UserHighlight?>(null) }
var showHighlightDeleteDialogFor by remember { mutableStateOf<UserHighlight?>(null) }
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
) {
items(
items = userHighlights.sortedBy { it.chapterIndex },
key = { it.id }
) { highlight ->
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: "Unknown Chapter"
ListItem(
headlineContent = {
Text(
text = highlight.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(highlight.color.color, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = {
Box {
IconButton(onClick = { highlightMenuExpandedFor = highlight }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
expanded = highlightMenuExpandedFor == highlight,
onDismissRequest = { highlightMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showHighlightDeleteDialogFor = highlight
highlightMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToHighlight(highlight) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
showHighlightDeleteDialogFor?.let { highlightToDelete ->
AlertDialog(
onDismissRequest = { showHighlightDeleteDialogFor = null },
title = { Text("Delete Highlight?") },
text = { Text("Are you sure you want to permanently delete this highlight?") },
confirmButton = {
TextButton(
onClick = {
onDeleteHighlight(highlightToDelete)
showHighlightDeleteDialogFor = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showHighlightDeleteDialogFor = null }) {
Text("Cancel")
}
}
)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchNavigationControls
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.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
import kotlin.math.max
import kotlin.math.min
/**
* Creates the search implementation for EPUB chapters.
*/
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
withContext(Dispatchers.Default) {
val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
if (isWordStart) {
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
}
} catch (e: Exception) {
Timber.e("Failed to search in chapter $chapterIndex", e)
}
}
results
}
}
/**
* Handles the navigation to a specific search result.
*/
fun performSearchResultNavigation(
index: Int,
searchState: SearchState,
renderMode: RenderMode,
currentChapterIndex: Int,
loadedChunkCount: Int,
webView: WebView?,
paginator: IPaginator?,
coroutineScope: CoroutineScope,
onVerticalChapterChange: (chapterIndex: Int, chunkIndex: Int, result: SearchResult) -> Unit,
onVerticalScrollToResult: (result: SearchResult) -> Unit,
onPaginatedScrollToPage: suspend (pageIndex: Int) -> Unit
) {
if (index !in searchState.searchResults.indices) return
val result = searchState.searchResults[index]
searchState.currentSearchResultIndex = index
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
if (currentChapterIndex != result.locationInSource) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
if (result.chunkIndex >= loadedChunkCount) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
webView?.let {
val js = "javascript:window.scrollToOccurrence(${result.occurrenceIndexInLocation});"
it.evaluateJavascript(js, null)
}
onVerticalScrollToResult(result)
}
}
}
RenderMode.PAGINATED -> {
paginator?.findPageForSearchResult(result) { pageIndex ->
coroutineScope.launch {
onPaginatedScrollToPage(pageIndex)
}
}
}
}
}
@Composable
fun EpubReaderSearchEffects(
searchState: SearchState,
webViewRef: WebView?,
currentChapterIndex: Int,
focusRequester: FocusRequester
) {
// 1. Auto-Highlight in WebView
LaunchedEffect(searchState.searchResults, currentChapterIndex) {
val query = searchState.searchQuery
if (query.isBlank()) {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
return@LaunchedEffect
}
val resultsInCurrentChapter = searchState.searchResults.any { it.locationInSource == currentChapterIndex }
if (resultsInCurrentChapter) {
webViewRef?.let { webView ->
val escapedQuery = escapeJsString(query)
val js = "javascript:window.highlightAllOccurrences('${escapedQuery}');"
Timber.d("Highligting: $js")
webView.evaluateJavascript(js, null)
}
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
// 2. Focus Management
LaunchedEffect(searchState.isSearchActive) {
if (searchState.isSearchActive) {
delay(100)
focusRequester.requestFocus()
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
}
@Composable
fun EpubReaderSearchOverlay(
searchState: SearchState,
onNavigateResult: (Int) -> Unit,
bottomPadding: Dp
) {
val keyboardController = LocalSoftwareKeyboardController.current
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
// Search Results Panel
AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
) {
SearchResultsPanel(
results = searchState.searchResults,
isSearching = searchState.isSearchInProgress,
onResultClick = { result ->
val resultIndex = searchState.searchResults.indexOf(result)
if (resultIndex != -1) {
onNavigateResult(resultIndex)
}
searchState.showSearchResultsPanel = false
keyboardController?.hide()
},
modifier = Modifier.padding(top = 50.dp)
)
}
AnimatedVisibility(
visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && searchState.hasResults,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = bottomPadding + 45.dp + 16.dp, end = 16.dp)
) {
SearchNavigationControls(
searchState = searchState,
onNavigate = { index -> onNavigateResult(index) }
)
}
}
}

View file

@ -0,0 +1,497 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
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.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import java.io.File
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"
private const val LINE_HEIGHT_KEY = "reader_line_height"
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
private const val FONT_FAMILY_KEY = "reader_font_family"
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
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")
}
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) {
DEFAULT("default", "", R.drawable.format_align_left, "Default"),
LEFT("left", "left", R.drawable.format_align_left, "Left"),
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
}
fun getComposeFontFamily(
font: ReaderFont,
customFontPath: String? = null,
assetManager: android.content.res.AssetManager? = null
): FontFamily {
if (customFontPath != null) {
return try {
FontFamily(Font(File(customFontPath)))
} catch (_: Exception) {
FontFamily.Default
}
}
if (assetManager != null) {
return try {
when (font) {
ReaderFont.ORIGINAL -> FontFamily.Default
ReaderFont.MERRIWEATHER -> FontFamily(Font("fonts/merriweather.ttf", assetManager))
ReaderFont.LATO -> FontFamily(Font("fonts/lato.ttf", assetManager))
ReaderFont.LORA -> FontFamily(Font("fonts/lora.ttf", assetManager))
ReaderFont.ROBOTO_MONO -> FontFamily(Font("fonts/roboto_mono.ttf", assetManager))
ReaderFont.LEXEND -> FontFamily(Font("fonts/lexend.ttf", assetManager))
}
} catch (_: Exception) {
FontFamily.Default
}
}
return FontFamily.Default
}
fun loadFontSelection(context: Context): Pair<ReaderFont, String?> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedVal = prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
return if (savedVal.startsWith("custom|")) {
val path = savedVal.substringAfter("custom|")
Pair(ReaderFont.ORIGINAL, path)
} else {
val font = ReaderFont.entries.find { it.id == savedVal } ?: ReaderFont.ORIGINAL
Pair(font, null)
}
}
fun saveReaderSettings(
context: Context,
fontSize: Float,
lineHeight: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat(FONT_SIZE_KEY, fontSize)
putFloat(LINE_HEIGHT_KEY, lineHeight)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
putString(FONT_FAMILY_KEY, fontFamily.id)
}
putString(TEXT_ALIGN_KEY, textAlign.id)
}
}
fun loadFontSize(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL)
}
fun loadLineHeight(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL)
}
fun loadTextAlign(context: Context): ReaderTextAlign {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id)
return ReaderTextAlign.entries.find { it.id == id } ?: ReaderTextAlign.DEFAULT
}
fun saveAutoScrollSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) }
}
fun loadAutoScrollSpeed(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(AUTO_SCROLL_SPEED_KEY, 0.8f)
}
fun saveTapToNavigateSetting(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, enabled) }
}
fun loadTapToNavigateSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, false)
}
fun saveVolumeScrollSetting(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(VOLUME_SCROLL_ENABLED_KEY, enabled) }
}
fun loadVolumeScrollSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
}
@Composable
fun ReaderTextFormatPanel(
isVisible: Boolean,
currentFontSize: Float,
onFontSizeChange: (Float) -> Unit,
currentLineHeight: Float,
onLineHeightChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
currentTextAlign: ReaderTextAlign,
onTextAlignChange: (ReaderTextAlign) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
shadowElevation = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Font Family", style = MaterialTheme.typography.labelLarge)
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(40.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp)
) {
val displayName = currentCustomFontName ?: currentFont.displayName
Text(
text = displayName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp)
)
}
}
}
HorizontalDivider()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Font Size", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentFontSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.fillMaxWidth()
)
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Line Spacing", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentLineHeight),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f,
steps = 14,
modifier = Modifier.fillMaxWidth()
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box {
var alignmentMenuExpanded by remember { mutableStateOf(false) }
Surface(
onClick = { alignmentMenuExpanded = true },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
painter = androidx.compose.ui.res.painterResource(id = currentTextAlign.iconResId),
contentDescription = "Text Alignment",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
DropdownMenu(
expanded = alignmentMenuExpanded,
onDismissRequest = { alignmentMenuExpanded = false }
) {
ReaderTextAlign.entries.forEach { align ->
DropdownMenuItem(
text = { Text(align.displayName) },
leadingIcon = {
Icon(
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
contentDescription = null
)
},
trailingIcon = {
if (align == currentTextAlign) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
},
onClick = {
onTextAlignChange(align)
alignmentMenuExpanded = false
}
)
}
}
}
TextButton(onClick = onReset) {
Text("Reset Defaults")
}
}
}
}
}
}
@Composable
fun FontSelectionSheetContent(
currentFont: ReaderFont,
currentCustomFontPath: String?,
onFontSelected: (ReaderFont, String?) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onDismiss: () -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { onImportFont(it) }
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Select Font", style = MaterialTheme.typography.titleMedium)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text("Presets") })
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text("Imported") })
}
Box(modifier = Modifier.heightIn(min = 200.dp, max = 400.dp)) {
when (selectedTabIndex) {
0 -> {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(ReaderFont.entries.toTypedArray()) { font ->
val isSelected = currentCustomFontPath == null && currentFont == font
ListItem(
headlineContent = {
Text(font.displayName, fontFamily = getComposeFontFamily(font, null))
},
trailingContent = {
if (isSelected) Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable { onFontSelected(font, null) },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
}
}
}
1 -> {
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Button(
onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Import from Files")
}
}
if (customFonts.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"No imported fonts yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 32.dp)
)
}
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
items(customFonts) { fontEntity ->
val isSelected = currentCustomFontPath == fontEntity.path
val fontFamily = remember(fontEntity.path) {
try { FontFamily(androidx.compose.ui.text.font.Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
}
ListItem(
headlineContent = {
Text(fontEntity.displayName, fontFamily = fontFamily)
},
trailingContent = {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
},
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,121 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.view.KeyEvent
import android.view.View
import android.view.Window
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.aryan.reader.RenderMode
@Composable
fun EpubReaderSystemUiController(
window: Window?,
view: View,
showBars: Boolean,
initialIsAppearanceLightStatusBars: Boolean,
initialSystemBarsBehavior: Int
) {
val isDarkTheme = isSystemInDarkTheme()
// 1. Handle Immersive Mode (Enter/Exit)
DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) {
if (window == null) {
Timber.w("Window is null, cannot control system UI.")
return@DisposableEffect onDispose {}
}
val insetsController = WindowCompat.getInsetsController(window, view)
Timber.d("Applying immersive mode.")
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
Timber.d("Restoring system UI.")
WindowCompat.setDecorFitsSystemWindows(window, true)
insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
insetsController.systemBarsBehavior = initialSystemBarsBehavior
}
}
// 2. Handle Status Bar Appearance (Dark/Light theme)
LaunchedEffect(window, view, isDarkTheme) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
}
}
// 3. Handle Show/Hide Bars dynamically
LaunchedEffect(showBars, window, view) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
if (showBars) {
insetsController.show(WindowInsetsCompat.Type.navigationBars())
} else {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
}
}
}
}
fun Modifier.volumeScrollHandler(
volumeScrollEnabled: Boolean,
renderMode: RenderMode,
isTtsActive: Boolean,
isMusicActive: Boolean,
currentScrollY: Int,
currentScrollHeight: Int,
currentClientHeight: Int,
currentChapterIndex: Int,
totalChapters: Int,
onScrollBy: (Int) -> Unit,
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit
): Modifier = this.onPreviewKeyEvent { keyEvent ->
val shouldHandle = volumeScrollEnabled &&
renderMode == RenderMode.VERTICAL_SCROLL &&
!isTtsActive &&
!isMusicActive
if (!shouldHandle) return@onPreviewKeyEvent false
val isVolumeKey = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_UP
if (!isVolumeKey) return@onPreviewKeyEvent false
if (keyEvent.type == KeyEventType.KeyDown) {
val direction = if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) 1 else -1
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
if (direction == -1 && currentScrollY == 0) {
// Top -> Prev Chapter
if (currentChapterIndex > 0) {
onNavigateChapter(-1, ChapterScrollPosition.END)
}
} else if (direction == 1 && isAtBottom) {
// Bottom -> Next Chapter
if (currentChapterIndex < totalChapters - 1) {
onNavigateChapter(1, ChapterScrollPosition.START)
}
} else {
// Scroll
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
}
}
true
}

View file

@ -0,0 +1,319 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo(
val text: String,
val cfi: String,
val offset: Int
)
@Suppress("unused")
@OptIn(UnstableApi::class)
fun saveTtsMode(context: Context, mode: TtsMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@Suppress("unused")
@OptIn(UnstableApi::class)
fun loadTtsMode(): TtsMode {
// For this release, Cloud TTS is disabled. Force BASE mode.
return TtsMode.BASE
}
/**
* Helper to update the WebView auto-scroll state.
*/
fun updateAutoScrollJs(webView: WebView?, playing: Boolean, speed: Float) {
if (playing) {
val jsCommand = "javascript:window.autoScroll.start($speed);"
webView?.evaluateJavascript(jsCommand, null)
} else {
webView?.evaluateJavascript("javascript:window.autoScroll.stop();", null)
}
}
/**
* Logic for triggering the actual TTS start based on the current mode.
*/
fun initiateTtsPlayback(
renderMode: RenderMode,
webView: WebView?,
onPaginatedStart: () -> Unit
) {
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.d("Vertical: requesting text extraction via JS.")
webView?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}
RenderMode.PAGINATED -> {
onPaginatedStart()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
@Composable
fun TtsSessionObserver(
ttsState: TtsPlaybackManager.TtsState,
ttsController: TtsController,
currentRenderMode: RenderMode,
chapters: List<EpubChapter>,
epubBookTitle: String,
coverImagePath: String?,
// Vertical Mode Dependencies
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
// Paginated Mode Dependencies
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
onTtsChapterIndexChange: (Int?) -> Unit,
onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean,
scope: CoroutineScope
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val isChangingConfig = ttsState.isChangingConfig
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (!isChangingConfig && isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
handleVerticalAutoAdvance(
webViewRef = webViewRef,
loadedChunkCount = loadedChunkCount,
totalChunksInChapter = totalChunksInChapter,
currentTtsChapterIndex = ttsChapterIndex,
totalChapters = chapters.size,
onNavigateToNextChapter = { nextIndex ->
onToggleTtsStartOnLoad(true)
onNavigateToChapter(nextIndex)
},
onStopTts = { onTtsChapterIndexChange(null) }
)
} else if (currentRenderMode == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
ttsController = ttsController,
paginator = paginator,
pagerState = pagerState,
chapters = chapters,
currentTtsChapterIndex = ttsChapterIndex,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
// Playback stopped/paused
if (userStoppedTts || sessionEndedByStop) {
Timber.d("TTS stopped by user/stop command.")
onTtsChapterIndexChange(null)
}
}
}
prevTtsState.value = ttsState
}
}
/**
* Handles highlighting text in WebView (Vertical) or turning pages (Paginated)
* based on playback progress.
*/
@OptIn(UnstableApi::class)
@Composable
fun TtsHighlightHandler(
ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode,
webViewRef: WebView?,
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
scope: CoroutineScope
) {
// 1. Vertical & General Highlighting (WebView)
LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) {
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
val offset = ttsState.startOffsetInSource
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
// Use window.highlightFromCfi defined in epub_reader.js
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
webViewRef?.evaluateJavascript(jsCommand, null)
} else {
if (!ttsState.isPlaying && !ttsState.isLoading) {
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
}
}
}
// 2. Paginated Page Turning (Sentence/Fragment level)
LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) {
if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect
val cfi = ttsState.sourceCfi ?: return@LaunchedEffect
val offset = ttsState.startOffsetInSource.takeIf { it != -1 } ?: return@LaunchedEffect
val chapterIdx = ttsChapterIndex ?: return@LaunchedEffect
val pag = paginator ?: return@LaunchedEffect
val targetPage = pag.findPageForCfiAndOffset(chapterIdx, cfi, offset)
if (targetPage != null && targetPage != pagerState.currentPage) {
// Prevent backward jumps during reading (unless significant) to avoid jitter
if (targetPage >= pagerState.currentPage) {
scope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
}
}
}
// --- Internal Helper Functions ---
private fun handleVerticalAutoAdvance(
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
currentTtsChapterIndex: Int?,
totalChapters: Int,
onNavigateToNextChapter: (Int) -> Unit,
onStopTts: () -> Unit
) {
if (loadedChunkCount < totalChunksInChapter) {
Timber.d("Vertical: Loading next chunk for TTS.")
webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}, 500)
} else {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) {
Timber.d("Vertical: Chapter finished, moving to next.")
onNavigateToNextChapter(currentTtsChapterIndex + 1)
} else {
Timber.d("Vertical: End of book.")
onStopTts()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
private fun handlePaginatedAutoAdvance(
ttsController: TtsController,
paginator: IPaginator?,
pagerState: PagerState,
chapters: List<EpubChapter>,
currentTtsChapterIndex: Int?,
epubBookTitle: String,
coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope
) {
val lastPlayedChapter = currentTtsChapterIndex
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = lastPlayedChapter + 1
var foundContent = false
val bookPaginator = paginator as? BookPaginator
if (bookPaginator == null) {
onUpdateTtsChapter(null)
return@launch
}
while (chapterToTry < chapters.size) {
// Visually scroll to start of chapter
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
delay(300)
}
val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry)
if (!nextChapterChunks.isNullOrEmpty()) {
Timber.d("Paginated: Found content in chapter $chapterToTry. Starting.")
onUpdateTtsChapter(chapterToTry)
val chapterTitle = chapters.getOrNull(chapterToTry)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = nextChapterChunks,
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = "BASE" // Defaulting to Base for safety
)
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
// Visually flip through empty pages if needed
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {
pagerState.animateScrollToPage(targetPage!! + i)
delay(400)
}
}
chapterToTry++
}
}
if (!foundContent) {
Timber.d("Paginated: No more content found.")
onUpdateTtsChapter(null)
}
}
} else {
onUpdateTtsChapter(null)
}
}

View file

@ -0,0 +1,121 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.JavascriptInterface
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.json.JSONObject
import kotlin.math.min
val DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP = 100.dp
val PAGE_INFO_BAR_HEIGHT = 25.dp
@Composable
fun ChapterChangeIndicator(
text: String,
progress: Float,
isPullingDown: Boolean,
modifier: Modifier = Modifier
) {
val alpha = min(1f, progress * 1.5f)
if (alpha > 0.1f) {
Surface(
modifier = modifier
.fillMaxWidth()
.alpha(alpha)
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f),
tonalElevation = 4.dp
) {
Column(
modifier = Modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (isPullingDown) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(20.dp * min(1f, progress + 0.2f))
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (progress >= 1.0f) text else "Pull further... (${(progress * 100).toInt()}%)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.inverseOnSurface,
textAlign = TextAlign.Center
)
}
}
}
}
enum class ChapterScrollPosition {
START, END
}
data class SelectionRect(
val x: Float,
val y: Float,
val width: Float,
val height: Float,
val text: String
)
interface TextSelectionListener {
fun onTextSelected(rect: SelectionRect)
fun onSelectionCleared()
}
@Suppress("unused")
class TextSelectionJsInterface(private val listener: TextSelectionListener) {
@JavascriptInterface
fun onTextSelected(rectJson: String) {
try {
val json = JSONObject(rectJson)
val rect = SelectionRect(
x = json.getDouble("x").toFloat(),
y = json.getDouble("y").toFloat(),
width = json.getDouble("width").toFloat(),
height = json.getDouble("height").toFloat(),
text = json.getString("text")
)
listener.onTextSelected(rect)
} catch (e: Exception) {
Timber.e(e, "Error parsing selection rect JSON")
}
}
@JavascriptInterface
fun onSelectionCleared() {
listener.onSelectionCleared()
}
}
class PageInfoBridge(
private val onUpdate: (scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) -> Unit
) {
@JavascriptInterface
fun updateScrollState(scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) {
val fragment = if (activeFragmentId == "null" || activeFragmentId.isNullOrBlank()) null else activeFragmentId
Timber.tag("FRAG_NAV_DEBUG").d("Bridge received fragmentId: $fragment")
onUpdate(scrollY, scrollHeight, clientHeight, fragment)
}
}

View file

@ -0,0 +1,302 @@
// InteractiveWebView.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.Context
import timber.log.Timber
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.webkit.WebView
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.View
import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
@SuppressLint("ViewConstructor")
class InteractiveWebView(
context: Context,
private val onSingleTap: () -> Unit,
private val onPotentialScroll: () -> Unit,
private val onOverScrollTop: (dragAmount: Float) -> Unit,
private val onOverScrollBottom: (dragAmount: Float) -> Unit,
private val onReleaseOverScrollTop: () -> Unit,
private val onReleaseOverScrollBottom: () -> Unit,
private val onShowCustomSelectionMenu: (selectedText: String, selectionBounds: Rect, finishActionModeCallback: () -> Unit) -> Unit,
private val onHideCustomSelectionMenu: () -> Unit
) : WebView(context) {
companion object {
private const val DRAG_SENSITIVITY_PX = 20f
}
private var startY: Float = 0f
private var initialDragY: Float = 0f
private var currentDragOperation: DragOperation = DragOperation.NONE
private val scrollStopHandler = Handler(Looper.getMainLooper())
private var scrollStopRunnable: Runnable? = null
private var mCustomCallback: ActionMode.Callback? = null
private val gestureDetector =
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed")
onSingleTap()
return true
}
})
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
gestureDetector.onTouchEvent(event)
var overscrollEventHandled = false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
startY = event.y
}
MotionEvent.ACTION_MOVE -> {
onPotentialScroll()
val deltaYSinceActionDown = event.y - startY
val oldDragOperation = currentDragOperation
if (currentDragOperation == DragOperation.PULLING_DOWN_FROM_TOP) {
val dragDistance = event.y - initialDragY
onOverScrollTop(dragDistance.coerceAtLeast(0f))
overscrollEventHandled = true
} else if (currentDragOperation == DragOperation.PULLING_UP_FROM_BOTTOM) {
val dragDistance = initialDragY - event.y
onOverScrollBottom(dragDistance.coerceAtLeast(0f))
overscrollEventHandled = true
} else {
if (deltaYSinceActionDown > DRAG_SENSITIVITY_PX && !canScrollVertically(-1)) {
currentDragOperation = DragOperation.PULLING_DOWN_FROM_TOP
initialDragY = event.y
onOverScrollTop(0f)
overscrollEventHandled = true
} else if (deltaYSinceActionDown < -DRAG_SENSITIVITY_PX && !canScrollVertically(
1
)
) {
currentDragOperation = DragOperation.PULLING_UP_FROM_BOTTOM
initialDragY = event.y
onOverScrollBottom(0f)
overscrollEventHandled = true
}
}
if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) {
Timber.d("Drag operation started ($currentDragOperation), disabling text selection."
)
evaluateJavascript(
"javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);",
null
)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val wasDragging = currentDragOperation != DragOperation.NONE
if (currentDragOperation == DragOperation.PULLING_DOWN_FROM_TOP) {
onReleaseOverScrollTop()
overscrollEventHandled = true
} else if (currentDragOperation == DragOperation.PULLING_UP_FROM_BOTTOM) {
onReleaseOverScrollBottom()
overscrollEventHandled = true
}
currentDragOperation = DragOperation.NONE
if (wasDragging) {
Timber.d("Drag operation ended, enabling text selection.")
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
}
}
}
parent?.requestDisallowInterceptTouchEvent(currentDragOperation != DragOperation.NONE)
if (overscrollEventHandled) {
return true
}
return super.onTouchEvent(event)
}
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
if (type == ActionMode.TYPE_FLOATING) {
if (mCustomCallback == null) {
mCustomCallback = object : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onCreateActionMode")
menu.clear()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onPrepareActionMode")
menu.clear()
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
onHideCustomSelectionMenu()
mode.finish()
}
}
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
return false
}
override fun onDestroyActionMode(mode: ActionMode) {
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
onHideCustomSelectionMenu()
}
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
super.onGetContentRect(mode, view, outRect)
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
}
}
}
return super.startActionMode(mCustomCallback, type)
}
return super.startActionMode(originalCallback, type)
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
scrollStopRunnable = Runnable {
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank()) {
Timber.d("Selection exists after scroll. Restarting action mode.")
mCustomCallback?.let {
startActionMode(it, ActionMode.TYPE_FLOATING)
}
}
}
}
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
}
}