Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal file
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
304
app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
Normal file
304
app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
Normal 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
3003
app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
Normal file
3003
app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
319
app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
Normal file
319
app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue