) = withContext(Dispatchers.IO) {
+ if (items.isEmpty()) return@withContext
+ items.chunked(300).forEach { chunk ->
+ database.withTransaction {
+ chunk.forEach { item ->
+ recentFileDao.updateExtractedMetadata(
+ bookId = item.bookId,
+ coverImagePath = item.coverImagePath,
+ title = item.title,
+ author = item.author,
+ fileSize = item.fileSize
+ )
+ }
+ }
+ }
+ Timber.tag(ReaderPerfLog.TAG).d("Metadata extraction batch updated ${items.size} rows.")
}
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
index 7e32bb5..3b5be54 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
@@ -23,6 +23,7 @@ import android.graphics.Bitmap
import com.aryan.reader.epub.EpubParser.EpubPageTarget
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
+import java.io.File
@Serializable
data class EpubTocEntry(
@@ -50,4 +51,15 @@ data class EpubBook(
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
-)
\ No newline at end of file
+)
+
+fun EpubBook.hasReadableExtractedContent(): Boolean {
+ if (extractionBasePath.isBlank()) return false
+ val extractionDir = File(extractionBasePath)
+ if (!extractionDir.isDirectory) return false
+ if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
+
+ return chapters.all { chapter ->
+ File(extractionDir, chapter.htmlFilePath).isFile
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
index c3fdb28..aaecd62 100644
--- a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
+++ b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
@@ -9,8 +9,12 @@ object ImportedFileCache {
private const val TEMP_PREFIX = "imported_file_tmp_"
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
+ fun activeBookDirName(bookId: String): String {
+ return "$ACTIVE_PREFIX${bookMarker(bookId)}"
+ }
+
fun activeBookDir(context: Context, bookId: String): File {
- return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
+ return File(context.cacheDir, activeBookDirName(bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
@@ -39,6 +43,7 @@ object ImportedFileCache {
fun clearBookCache(context: Context, bookId: String) {
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
+ legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
clearTemporaryBookDirs(context, bookId)
}
@@ -69,6 +74,10 @@ object ImportedFileCache {
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
}
+ private fun legacyActiveBookDir(context: Context, bookId: String): File {
+ return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
+ }
+
private fun bookMarker(bookId: String): String {
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
val hash = bookId.hashCode().toLong() and 0xffffffffL
diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
index e52c239..ef46c8f 100644
--- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt
@@ -109,10 +109,20 @@ class MobiParser(private val context: Context) {
private external fun parseMobiFile(filePath: String): ParsedMobiData?
companion object {
- init {
+ private val nativeLoadError: Throwable? = try {
System.loadLibrary("mobi")
System.loadLibrary("native-lib")
+ null
+ } catch (t: Throwable) {
+ Timber.e(t, "MOBI native parser is unavailable on this device.")
+ t
}
+
+ val isNativeParserAvailable: Boolean
+ get() = nativeLoadError == null
+
+ fun nativeParserUnavailableMessage(): String =
+ nativeLoadError?.message ?: "MOBI native parser is unavailable on this device."
}
suspend fun createMobiBook(
@@ -122,6 +132,11 @@ class MobiParser(private val context: Context) {
parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook? = withContext(Dispatchers.IO) {
+ if (!isNativeParserAvailable) {
+ Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}")
+ return@withContext null
+ }
+
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try {
tempFile.outputStream().use { output ->
diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
index 8f8caea..001b5e0 100644
--- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
+++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
@@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.safety.Safelist
import org.zwobble.mammoth.DocumentConverter
import timber.log.Timber
import java.io.File
@@ -52,6 +54,15 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
}
+ private val htmlSafelist = Safelist.relaxed()
+ .addTags("article", "aside", "details", "div", "figcaption", "figure", "footer", "header", "main", "section", "summary")
+ .addAttributes(":all", "class", "dir", "id", "lang", "title")
+ .addAttributes("a", "name", "target")
+ .addProtocols("a", "href", "http", "https", "mailto", "tel", "#")
+ .addProtocols("img", "src", "http", "https", "data", "file", "content")
+
+ private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
+
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
@@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) {
): EpubBook {
val lowerHint = originalBookNameHint.lowercase()
- val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv")
- val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) }
+ val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
+ lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
+ val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
@@ -99,7 +111,7 @@ class SingleFileImporter(private val context: Context) {
writer.write("\n\n\n")
}
- val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ','
+ val delimiter = if (originalBookNameHint.lowercase().let { it.endsWith(".tsv") || it.endsWith(".tsv.txt") }) '\t' else ','
inputStream.bufferedReader().use { reader ->
var line = reader.readLine()
@@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
)
}
- File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
- val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
- if (!exists()) mkdirs()
- }
+ val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = "Page $pageNum"
val document = parser.parse(rawText)
- val htmlBody = renderer.render(document)
+ val htmlBody = sanitizeHtmlFragment(renderer.render(document))
val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName)
@@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
)
}
- File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
- val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
- if (!exists()) mkdirs()
- }
+ val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
)
}
- File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
- val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
- if (!exists()) mkdirs()
- }
+ val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
val chapters = mutableListOf()
inputStream.bufferedReader().use { reader ->
+ var inScript = false
var inStyle = false
var inBody = false
var pageNum = 1
@@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim()
+ if (inScript) {
+ if (trimmed.contains("").substringBefore("")
@@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
return@withContext book
}
+ private fun sanitizeHtmlFragment(html: String): String {
+ return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
+ }
+
private suspend fun parseDocx(
inputStream: InputStream,
originalBookNameHint: String,
@@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
)
}
- File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
- val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
- if (!exists()) mkdirs()
- }
+ val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@@ -754,8 +768,9 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = if (pageNum > 1 || bodyContent.contains("\n\n${title.replace("\"", """)}\n\n\n\n${bodyContent.trim()}\n\n"
+ val fullHtml = "\n\n\n${title.replace("\"", """)}\n\n\n\n${sanitizedBodyContent.trim()}\n\n"
file.writeText(fullHtml)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index cdf3312..714749a 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -26,10 +26,8 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
-import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Rect
-import android.util.Base64
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
@@ -83,13 +81,12 @@ 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.ReaderTexture
+import com.aryan.reader.getReaderTextureDataUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
-import java.io.ByteArrayOutputStream
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
@@ -351,6 +348,7 @@ fun ChapterWebView(
currentParagraphGap: Float,
currentImageSize: Float,
currentHorizontalMargin: Float,
+ currentVerticalMargin: Float,
onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit,
@@ -386,7 +384,8 @@ fun ChapterWebView(
activeHighlightPalette: List,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
- activeTextureId: String? = null
+ activeTextureId: String? = null,
+ activeTextureAlpha: Float = 0.55f
) {
Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
@@ -406,17 +405,8 @@ fun ChapterWebView(
val textureBase64 by remember(activeTextureId) {
mutableStateOf(
- activeTextureId?.let { id ->
- ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
- val bmp = BitmapFactory.decodeResource(context.resources, resId)
- val out = ByteArrayOutputStream()
- bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
- "data:image/png;base64," + Base64.encodeToString(
- out.toByteArray(),
- Base64.NO_WRAP
- )
- }
- })
+ getReaderTextureDataUri(context, activeTextureId)
+ )
}
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
@@ -476,10 +466,10 @@ fun ChapterWebView(
Box(modifier = modifier.fillMaxSize()) {
- LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) {
+ LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) {
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
- localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null)
+ localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null)
}
key(
@@ -489,6 +479,7 @@ fun ChapterWebView(
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
+ currentVerticalMargin,
currentFontFamily,
currentTextAlign
) {
@@ -737,7 +728,7 @@ fun ChapterWebView(
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex =
String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
- view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});",
+ view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});",
null)
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
@@ -782,7 +773,7 @@ fun ChapterWebView(
}
view?.evaluateJavascript(
- "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
+ "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)
@@ -946,7 +937,7 @@ fun ChapterWebView(
)
webView.evaluateJavascript(
- "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
+ "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
index dde0201..0aab63d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
@@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
-import com.aryan.reader.BuildConfig
import com.aryan.reader.R
+import com.aryan.reader.areReaderAiFeaturesEnabled
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
)
// ── Dictionary ──
- if (BuildConfig.FLAVOR != "oss") {
+ if (areReaderAiFeaturesEnabled(context)) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -342,4 +342,4 @@ private fun AppSelectionDropdown(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
index 620eeca..c8e776d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
@@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.aryan.reader.AiDefinitionPopup
+import com.aryan.reader.AiFeature
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet
+import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
+import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator
@@ -55,6 +58,7 @@ import java.net.URL
*/
suspend fun summarizeBookContent(
content: String,
+ context: Context,
authToken: String?,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
onUpdate: (String) -> Unit,
@@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
}
Timber.d("Starting summarization for content of length: ${content.length}")
+ @Suppress("KotlinConstantConditions")
+ if (BuildConfig.FLAVOR == "oss") {
+ if (BuildConfig.IS_OFFLINE) {
+ onError("AI features are unavailable in the offline OSS build.")
+ onFinish()
+ return
+ }
+ callByokTextAi(
+ context = context,
+ feature = AiFeature.SUMMARIZE,
+ systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
+ userPrompt = content,
+ temperature = 0.2,
+ maxTokens = 8192,
+ onUpdate = onUpdate,
+ onError = onError
+ )
+ onFinish()
+ return
+ }
+
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
@@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
summarizeBookContent(
content = textToSummarize,
+ context = context,
authToken = authToken,
onUsageReceived = { cost, _ ->
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
@@ -387,4 +413,4 @@ fun EpubReaderAiOverlays(
}
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
index 0e82295..f5014e6 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
@@ -26,6 +26,7 @@ import android.graphics.Canvas
import android.os.Build
import android.webkit.WebView
import androidx.annotation.RequiresApi
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
@@ -39,11 +40,13 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -64,6 +67,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -97,10 +101,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -112,9 +114,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -122,6 +127,9 @@ 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.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.zIndex
import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
@@ -130,6 +138,7 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
+import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator
@@ -161,6 +170,96 @@ enum class ReaderTool(val title: String, val category: String) {
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
}
+enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
+
+data class FlatToolItem(
+ val id: String,
+ val type: FlatItemType,
+ val tool: ReaderTool? = null,
+ val section: ToolbarSection? = null,
+ val title: String? = null
+)
+
+fun sanitizePlaceholders(list: List): List {
+ val result = mutableListOf()
+ val sectionMap = mutableMapOf>()
+ ToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
+
+ list.forEach { item ->
+ if (item.type == FlatItemType.TOOL) {
+ item.section?.let { sectionMap[it]?.add(item) }
+ }
+ }
+
+ ToolbarSection.entries.forEach { section ->
+ result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
+
+ val tools = sectionMap[section] ?: emptyList()
+ if (tools.isEmpty()) {
+ result.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
+ } else {
+ result.addAll(tools)
+ }
+ }
+
+ // Maintain More menu items
+ list.filter { it.type == FlatItemType.MORE_HEADER || it.type == FlatItemType.MORE_TOOL }.forEach {
+ result.add(it)
+ }
+
+ return result
+}
+
+class DragDropState(
+ val lazyListState: LazyListState,
+ val onMove: (String, String) -> Unit
+) {
+ var draggedItemId by mutableStateOf(null)
+ var dragOffset by mutableStateOf(Offset.Zero)
+
+ fun onDragStart(id: String) {
+ draggedItemId = id
+ dragOffset = Offset.Zero
+ }
+
+ fun onDrag(delta: Offset) {
+ val draggedId = draggedItemId ?: return
+ dragOffset += delta
+
+ val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
+ val currentItem = visibleItems.find { it.key == draggedId } ?: return
+
+ val startY = currentItem.offset + dragOffset.y
+ val center = startY + currentItem.size / 2f
+
+ val targetItem = visibleItems.find {
+ it.key != draggedId && center >= it.offset && center <= (it.offset + it.size)
+ }
+
+ if (targetItem != null) {
+ onMove(draggedId, targetItem.key.toString())
+ // Adjust visual offset to prevent snapping when items swap in the layout
+ dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
+ }
+ }
+
+ fun onDragEnd() {
+ draggedItemId = null
+ dragOffset = Offset.Zero
+ }
+}
+
+private val epubToolbarTools = setOf(
+ ReaderTool.DICTIONARY,
+ ReaderTool.THEME,
+ ReaderTool.SLIDER,
+ ReaderTool.TOC,
+ ReaderTool.FORMAT,
+ ReaderTool.SEARCH,
+ ReaderTool.AI_FEATURES,
+ ReaderTool.TTS_CONTROLS
+)
+
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
@@ -186,8 +285,16 @@ fun EpubReaderTopBar(
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
+ onOpenSlider: () -> Unit,
+ onOpenDrawer: () -> Unit,
+ onToggleFormat: () -> Unit,
+ onToggleSearch: () -> Unit,
+ onOpenAiHub: () -> Unit,
+ onToggleTts: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
hiddenTools: Set,
+ toolOrder: List,
+ bottomTools: Set,
onCustomizeTools: () -> Unit,
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
@@ -235,41 +342,101 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
- if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_dictionary),
- description = stringResource(R.string.tooltip_dictionary_desc),
- onClick = onOpenDictionarySettings
- ) {
- Icon(
- painter = painterResource(id = R.drawable.dictionary),
- contentDescription = stringResource(R.string.content_desc_dictionary_settings)
- )
+ toolOrder
+ .filter { it in epubToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+ .forEach { tool ->
+ when (tool) {
+ ReaderTool.DICTIONARY -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_dictionary),
+ description = stringResource(R.string.tooltip_dictionary_desc),
+ onClick = onOpenDictionarySettings
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.dictionary),
+ contentDescription = stringResource(R.string.content_desc_dictionary_settings)
+ )
+ }
+ ReaderTool.THEME -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_theme),
+ description = stringResource(R.string.tooltip_theme_desc),
+ onClick = onOpenThemeSettings
+ ) {
+ Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
+ }
+ ReaderTool.SLIDER -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_slider),
+ description = stringResource(R.string.tooltip_slider_desc),
+ onClick = onOpenSlider,
+ enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+ ) {
+ Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
+ }
+ ReaderTool.TOC -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_toc),
+ description = stringResource(R.string.tooltip_toc_desc),
+ onClick = onOpenDrawer
+ ) {
+ Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
+ }
+ ReaderTool.FORMAT -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_format),
+ description = stringResource(R.string.tooltip_format_desc),
+ onClick = onToggleFormat
+ ) {
+ Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting))
+ }
+ ReaderTool.SEARCH -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_search),
+ description = stringResource(R.string.tooltip_search_desc),
+ onClick = onToggleSearch
+ ) {
+ Icon(Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
+ }
+ ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+ TooltipIconButton(
+ text = stringResource(R.string.tooltip_ai),
+ description = stringResource(R.string.tooltip_ai_desc),
+ onClick = onOpenAiHub
+ ) {
+ Icon(painter = painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.ai_features_title))
+ }
+ }
+ ReaderTool.TTS_CONTROLS -> TooltipIconButton(
+ text = if (isTtsActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
+ description = if (isTtsActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
+ onClick = onToggleTts
+ ) {
+ Icon(
+ painter = if (isTtsActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
+ contentDescription = if (isTtsActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts),
+ tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ else -> Unit
+ }
}
- }
- if (!hiddenTools.contains(ReaderTool.THEME.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_theme),
- description = stringResource(R.string.tooltip_theme_desc),
- onClick = onOpenThemeSettings
- ) {
- Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
- }
- }
Box {
var showMoreMenu by remember { mutableStateOf(false) }
+ var showHiddenToolsExpanded by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
- onClick = { showMoreMenu = true }
+ onClick = {
+ showHiddenToolsExpanded = false
+ showMoreMenu = true
+ }
) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
}
DropdownMenu(
expanded = showMoreMenu,
- onDismissRequest = { showMoreMenu = false }
+ onDismissRequest = {
+ showHiddenToolsExpanded = false
+ showMoreMenu = false
+ }
) {
+ val hiddenToolbarTools = toolOrder.filter { it in epubToolbarTools && hiddenTools.contains(it.name) }
DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = {
@@ -282,6 +449,42 @@ fun EpubReaderTopBar(
)
HorizontalDivider()
+ if (hiddenToolbarTools.isNotEmpty()) {
+ DropdownMenuItem(
+ text = { Text("Hidden tools") },
+ onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
+ )
+ }
+ )
+ if (showHiddenToolsExpanded) {
+ hiddenToolbarTools.forEach { tool ->
+ HiddenEpubToolMenuItem(
+ tool = tool,
+ currentRenderMode = currentRenderMode,
+ isTtsActive = isTtsActive,
+ showMoreMenu = {
+ showHiddenToolsExpanded = false
+ showMoreMenu = false
+ },
+ onOpenDictionarySettings = onOpenDictionarySettings,
+ onOpenThemeSettings = onOpenThemeSettings,
+ onOpenSlider = onOpenSlider,
+ onOpenDrawer = onOpenDrawer,
+ onToggleFormat = onToggleFormat,
+ onToggleSearch = onToggleSearch,
+ onOpenAiHub = onOpenAiHub,
+ onToggleTts = onToggleTts
+ )
+ }
+ }
+ HorizontalDivider()
+ }
+
if (onToggleReflow != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
@@ -498,8 +701,12 @@ fun EpubReaderBottomBar(
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit,
+ onOpenDictionarySettings: () -> Unit,
+ onOpenThemeSettings: () -> Unit,
onToggleTts: () -> Unit,
hiddenTools: Set,
+ toolOrder: List,
+ bottomTools: Set,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@@ -521,93 +728,97 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
- if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_slider),
- description = stringResource(R.string.tooltip_slider_desc),
- onClick = onOpenSlider,
- enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
- ) {
- Icon(
- painter = painterResource(id = R.drawable.slider),
- contentDescription = stringResource(R.string.content_desc_navigate_slider)
- )
- }
- }
- if (!hiddenTools.contains(ReaderTool.TOC.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_toc),
- description = stringResource(R.string.tooltip_toc_desc),
- onClick = onOpenDrawer
- ) {
- Icon(
- imageVector = Icons.Default.Menu,
- contentDescription = stringResource(R.string.content_desc_chapters_menu)
- )
- }
- }
- if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_format),
- description = stringResource(R.string.tooltip_format_desc),
- onClick = onToggleFormat
- ) {
- Icon(
- painter = painterResource(id = R.drawable.format_size),
- contentDescription = stringResource(R.string.content_desc_text_formatting)
- )
- }
- }
- if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_search),
- description = stringResource(R.string.tooltip_search_desc),
- onClick = onToggleSearch
- ) {
- Icon(
- imageVector = Icons.Default.Search,
- contentDescription = stringResource(R.string.tooltip_search)
- )
- }
- }
-
- if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
- @Suppress(
- "KotlinConstantConditions",
- "SimplifyBooleanWithConstants"
- ) if (BuildConfig.FLAVOR != "oss") {
- TooltipIconButton(
- text = stringResource(R.string.tooltip_ai),
- description = stringResource(R.string.tooltip_ai_desc),
- onClick = onOpenAiHub
- ) {
- Icon(
- painter = painterResource(id = R.drawable.ai),
- contentDescription = stringResource(R.string.ai_features_title)
- )
+ toolOrder
+ .filter { it in epubToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+ .forEach { tool ->
+ when (tool) {
+ ReaderTool.DICTIONARY -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_dictionary),
+ description = stringResource(R.string.tooltip_dictionary_desc),
+ onClick = onOpenDictionarySettings
+ ) {
+ Icon(painter = painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings))
+ }
+ ReaderTool.THEME -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_theme),
+ description = stringResource(R.string.tooltip_theme_desc),
+ onClick = onOpenThemeSettings
+ ) {
+ Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
+ }
+ ReaderTool.SLIDER -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_slider),
+ description = stringResource(R.string.tooltip_slider_desc),
+ onClick = onOpenSlider,
+ enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.slider),
+ contentDescription = stringResource(R.string.content_desc_navigate_slider)
+ )
+ }
+ ReaderTool.TOC -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_toc),
+ description = stringResource(R.string.tooltip_toc_desc),
+ onClick = onOpenDrawer
+ ) {
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = stringResource(R.string.content_desc_chapters_menu)
+ )
+ }
+ ReaderTool.FORMAT -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_format),
+ description = stringResource(R.string.tooltip_format_desc),
+ onClick = onToggleFormat
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.format_size),
+ contentDescription = stringResource(R.string.content_desc_text_formatting)
+ )
+ }
+ ReaderTool.SEARCH -> TooltipIconButton(
+ text = stringResource(R.string.tooltip_search),
+ description = stringResource(R.string.tooltip_search_desc),
+ onClick = onToggleSearch
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.tooltip_search)
+ )
+ }
+ ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+ TooltipIconButton(
+ text = stringResource(R.string.tooltip_ai),
+ description = stringResource(R.string.tooltip_ai_desc),
+ onClick = onOpenAiHub
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ai),
+ contentDescription = stringResource(R.string.ai_features_title)
+ )
+ }
+ }
+ ReaderTool.TTS_CONTROLS -> TooltipIconButton(
+ text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
+ else stringResource(R.string.tooltip_tts_start),
+ description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
+ else stringResource(R.string.tooltip_tts_start_desc),
+ onClick = onToggleTts
+ ) {
+ Icon(
+ painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
+ id = R.drawable.text_to_speech
+ ),
+ contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
+ R.string.content_desc_start_tts
+ ),
+ tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ else -> Unit
}
}
- }
-
- if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
- TooltipIconButton(
- text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
- else stringResource(R.string.tooltip_tts_start),
- description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
- else stringResource(R.string.tooltip_tts_start_desc),
- onClick = onToggleTts
- ) {
- Icon(
- painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
- id = R.drawable.text_to_speech
- ),
- contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
- R.string.content_desc_start_tts
- ),
- tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
- )
- }
- }
}
}
}
@@ -1301,66 +1512,222 @@ fun AutoScrollControls(
@Composable
fun CustomizeToolsSheet(
hiddenTools: Set,
+ toolOrder: List,
+ bottomTools: Set,
onUpdate: (Set) -> Unit,
+ onOrderUpdate: (List) -> Unit,
+ onPlacementUpdate: (Set) -> Unit,
onDismiss: () -> Unit
) {
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- contentWindowInsets = { WindowInsets.navigationBars }
- ) {
- Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
- Text(
- text = "Customize Toolbar",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = stringResource(R.string.desc_customize_toolbar),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Spacer(modifier = Modifier.height(16.dp))
+ var localHiddenTools by remember { mutableStateOf(hiddenTools) }
- LazyColumn(modifier = Modifier.fillMaxWidth()) {
- ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
- item {
- Text(
- text = category,
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
- )
+ var flatItems by remember {
+ mutableStateOf(
+ run {
+ val toolbarTools = toolOrder.filter { it in epubToolbarTools }
+ val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+ val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+ val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
+ val moreTools = toolOrder.filter { it !in epubToolbarTools }
+
+ val list = mutableListOf()
+
+ ToolbarSection.entries.forEach { section ->
+ val tools = when(section) {
+ ToolbarSection.TOP -> topTools
+ ToolbarSection.BOTTOM -> bottomToolsList
+ ToolbarSection.HIDDEN -> hiddenToolsList
}
- items(tools) { tool ->
- Row(
+ list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
+ if (tools.isEmpty()) {
+ list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
+ } else {
+ tools.forEach { tool ->
+ list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
+ }
+ }
+ }
+
+ list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu"))
+ moreTools.forEach { tool ->
+ list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
+ }
+ list
+ }
+ )
+ }
+
+ val commitDragDrop = {
+ val newHidden = localHiddenTools.filter { toolName ->
+ toolOrder.find { it.name == toolName } !in epubToolbarTools
+ }.toMutableSet()
+
+ val newBottom = mutableSetOf()
+ val newOrder = mutableListOf()
+
+ flatItems.forEach { item ->
+ if (item.type == FlatItemType.TOOL && item.tool != null) {
+ newOrder.add(item.tool)
+ if (item.section == ToolbarSection.HIDDEN) newHidden.add(item.tool.name)
+ if (item.section == ToolbarSection.BOTTOM) newBottom.add(item.tool.name)
+ }
+ }
+
+ val moreTools = flatItems.filter { it.type == FlatItemType.MORE_TOOL }.mapNotNull { it.tool }
+ newOrder.addAll(moreTools)
+
+ localHiddenTools = newHidden
+ onUpdate(newHidden)
+ onPlacementUpdate(newBottom)
+ onOrderUpdate(newOrder)
+ }
+
+ val lazyListState = rememberLazyListState()
+ val dragDropState = remember {
+ DragDropState(lazyListState) { fromKey, toKey ->
+ val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
+ val toIndex = flatItems.indexOfFirst { it.id == toKey }
+ if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@DragDropState
+
+ val fromItem = flatItems[fromIndex]
+ if (fromItem.type != FlatItemType.TOOL) return@DragDropState
+
+ val toItem = flatItems[toIndex]
+ if (toItem.type == FlatItemType.MORE_HEADER || toItem.type == FlatItemType.MORE_TOOL) return@DragDropState
+
+ val newList = flatItems.toMutableList()
+ val movedItem = newList.removeAt(fromIndex)
+
+ val newToIndex = newList.indexOfFirst { it.id == toKey }
+ val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
+
+ newList.add(insertIndex, movedItem)
+
+ var actualSection = movedItem.section
+ for (i in insertIndex downTo 0) {
+ val item = newList[i]
+ if (item.type == FlatItemType.SECTION_HEADER) {
+ actualSection = item.section
+ break
+ }
+ }
+
+ newList[insertIndex] = movedItem.copy(section = actualSection)
+
+ flatItems = newList
+ }
+ }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.navigationBars),
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Customize Toolbar",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
+ }
+ }
+
+ LazyColumn(
+ state = lazyListState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 24.dp)
+ ) {
+ items(flatItems, key = { it.id }) { item ->
+ val isDragged = item.id == dragDropState.draggedItemId
+
+ val zIndex = if (isDragged) 1f else 0f
+ val elevation = if (isDragged) 8.dp else 0.dp
+ val scale = if (isDragged) 1.03f else 1f
+ val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
+
+ Box(
modifier = Modifier
.fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .clickable {
- val newSet = hiddenTools.toMutableSet()
- if (newSet.contains(tool.name)) newSet.remove(tool.name)
- else newSet.add(tool.name)
- onUpdate(newSet)
+ .then(if (isDragged) Modifier else Modifier.animateItem())
+ .zIndex(zIndex)
+ .graphicsLayer {
+ this.translationY = translationY
+ this.scaleX = scale
+ this.scaleY = scale
+ this.shadowElevation = elevation.toPx()
}
- .padding(vertical = 12.dp, horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
) {
- Text(
- text = tool.title,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface
- )
- Switch(
- checked = !hiddenTools.contains(tool.name),
- onCheckedChange = { isVisible ->
- val newSet = hiddenTools.toMutableSet()
- if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
- onUpdate(newSet)
+ when (item.type) {
+ FlatItemType.SECTION_HEADER -> {
+ Text(
+ text = item.title ?: "",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
+ )
}
- )
+ FlatItemType.EMPTY_PLACEHOLDER -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .padding(vertical = 4.dp)
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ FlatItemType.TOOL -> {
+ ToolbarDragRow(
+ tool = item.tool!!,
+ isDragging = isDragged,
+ onDragStart = { dragDropState.onDragStart(item.id) },
+ onDrag = { dragDropState.onDrag(it) },
+ onDragEnd = {
+ dragDropState.onDragEnd()
+ flatItems = sanitizePlaceholders(flatItems).toMutableList()
+ commitDragDrop()
+ }
+ )
+ }
+ FlatItemType.MORE_HEADER -> {
+ Text(
+ text = item.title ?: "More menu",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
+ )
+ }
+ FlatItemType.MORE_TOOL -> {
+ MoreToolVisibilityRow(
+ title = item.tool!!.title,
+ visible = !localHiddenTools.contains(item.tool.name),
+ onToggle = {
+ localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
+ localHiddenTools - item.tool.name
+ } else {
+ localHiddenTools + item.tool.name
+ }
+ onUpdate(localHiddenTools)
+ }
+ )
+ }
+ }
}
}
}
@@ -1369,6 +1736,144 @@ fun CustomizeToolsSheet(
}
}
+@Composable
+private fun ToolbarDragRow(
+ tool: ReaderTool,
+ isDragging: Boolean,
+ onDragStart: () -> Unit,
+ onDrag: (Offset) -> Unit,
+ onDragEnd: () -> Unit
+) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ shape = RoundedCornerShape(12.dp),
+ color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ToolPreviewIcon(tool)
+ Spacer(Modifier.width(16.dp))
+ Text(
+ text = tool.title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ Icon(
+ Icons.Default.Menu,
+ contentDescription = "Drag to reorder",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(32.dp)
+ .padding(6.dp)
+ .clip(CircleShape)
+ .pointerInput(tool) {
+ detectDragGestures(
+ onDragStart = { onDragStart() },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ onDrag(dragAmount)
+ },
+ onDragEnd = onDragEnd,
+ onDragCancel = onDragEnd
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun MoreToolVisibilityRow(
+ title: String,
+ visible: Boolean,
+ onToggle: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onToggle)
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ if (visible) {
+ Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ }
+ }
+}
+
+enum class ToolbarSection(val title: String) {
+ TOP("Top Bar"),
+ BOTTOM("Bottom Bar"),
+ HIDDEN("Hidden Tools")
+}
+
+@Composable
+private fun ToolPreviewIcon(tool: ReaderTool) {
+ when (tool) {
+ ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+ }
+}
+
+@Composable
+private fun HiddenEpubToolMenuItem(
+ tool: ReaderTool,
+ currentRenderMode: RenderMode,
+ isTtsActive: Boolean,
+ showMoreMenu: () -> Unit,
+ onOpenDictionarySettings: () -> Unit,
+ onOpenThemeSettings: () -> Unit,
+ onOpenSlider: () -> Unit,
+ onOpenDrawer: () -> Unit,
+ onToggleFormat: () -> Unit,
+ onToggleSearch: () -> Unit,
+ onOpenAiHub: () -> Unit,
+ onToggleTts: () -> Unit
+) {
+ val enabled = when (tool) {
+ ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
+ else -> true
+ }
+ DropdownMenuItem(
+ text = { Text(tool.title) },
+ enabled = enabled,
+ onClick = {
+ showMoreMenu()
+ when (tool) {
+ ReaderTool.DICTIONARY -> onOpenDictionarySettings()
+ ReaderTool.THEME -> onOpenThemeSettings()
+ ReaderTool.SLIDER -> onOpenSlider()
+ ReaderTool.TOC -> onOpenDrawer()
+ ReaderTool.FORMAT -> onToggleFormat()
+ ReaderTool.SEARCH -> onToggleSearch()
+ ReaderTool.AI_FEATURES -> onOpenAiHub()
+ ReaderTool.TTS_CONTROLS -> onToggleTts()
+ else -> Unit
+ }
+ },
+ leadingIcon = { ToolPreviewIcon(tool) }
+ )
+}
+
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1384,13 +1889,45 @@ fun TtsOverlayControls(
modifier: Modifier = Modifier,
credits: Int
) {
- val context = androidx.compose.ui.platform.LocalContext.current
+ val context = LocalContext.current
var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) }
val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
+ val progressPercent = ttsState.bookProgressPercent
+ val cleanChapterTitle = remember(ttsState.chapterTitle) {
+ ttsState.chapterTitle
+ ?.lineSequence()
+ ?.map { it.trim() }
+ ?.filter { it.isNotBlank() }
+ ?.joinToString(" - ")
+ ?.takeIf { it.isNotBlank() }
+ }
+ val chapterLabel = remember(ttsState.chapterIndex, ttsState.totalChapters, cleanChapterTitle) {
+ val chapterNumber = ttsState.chapterIndex?.plus(1)
+ val totalChapters = ttsState.totalChapters
+ when {
+ chapterNumber != null && totalChapters != null -> buildString {
+ append("Chapter $chapterNumber of $totalChapters")
+ if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
+ }
+ chapterNumber != null -> buildString {
+ append("Chapter $chapterNumber")
+ if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
+ }
+ !cleanChapterTitle.isNullOrBlank() -> cleanChapterTitle
+ else -> null
+ }
+ }
+ val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
+ if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
+ "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
+ } else {
+ null
+ }
+ }
val saveAndApply = {
saveTtsSpeechRate(context, rate)
@@ -1524,6 +2061,33 @@ fun TtsOverlayControls(
Spacer(Modifier.height(16.dp))
+ if (chapterLabel != null || progressPercent != null || chunkLabel != null) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ chapterLabel ?: "Reading",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(end = 8.dp)
+ )
+ Text(
+ listOfNotNull(progressPercent?.let { "$it%" }, chunkLabel).joinToString(" - "),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ }
+
// Middle Section: Controls
Row(
modifier = Modifier.fillMaxWidth(),
@@ -1555,41 +2119,151 @@ fun TtsOverlayControls(
Spacer(Modifier.width(16.dp))
// Unified Sliders Block
- Column(modifier = Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.tts_speed_short, "%.1f".format(rate)), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
- Slider(
- value = rate,
- onValueChange = {
- rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
- isDraggingRate = true; ttsController.pause()
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ stringResource(R.string.tts_speed_short, "%.1f".format(rate)),
+ style = MaterialTheme.typography.labelMedium
+ )
+ IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_speed), modifier = Modifier.size(16.dp))
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(
+ onClick = { rate = ((rate * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.dp))
+ }
+ Slider(
+ value = rate,
+ onValueChange = {
+ rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ isDraggingRate = true; ttsController.pause()
+ }
+ },
+ onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
+ valueRange = 0.5f..3.0f,
+ modifier = Modifier.weight(1f).height(20.dp),
+ thumb = {
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape)
+ )
+ },
+ track = { sliderState ->
+ 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(2.dp)
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
+ RoundedCornerShape(1.dp)
+ ),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction)
+ .height(2.dp)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ RoundedCornerShape(1.dp)
+ )
+ )
+ }
+ }
+ )
+ IconButton(
+ onClick = { rate = ((rate * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(3.0f); saveAndApply() },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
}
- },
- onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
- valueRange = 0.5f..3.0f,
- steps = 24,
- modifier = Modifier.weight(1f).height(24.dp)
- )
- IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
- Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_speed), modifier = Modifier.size(16.dp))
}
}
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.tts_pitch_short, "%.1f".format(pitch)), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
- Slider(
- value = pitch,
- onValueChange = {
- pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
- isDraggingPitch = true; ttsController.pause()
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ stringResource(R.string.tts_pitch_short, "%.1f".format(pitch)),
+ style = MaterialTheme.typography.labelMedium
+ )
+ IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_pitch), modifier = Modifier.size(16.dp))
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(
+ onClick = { pitch = ((pitch * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.dp))
+ }
+ Slider(
+ value = pitch,
+ onValueChange = {
+ pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+ isDraggingPitch = true; ttsController.pause()
+ }
+ },
+ onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
+ valueRange = 0.5f..2.0f,
+ modifier = Modifier.weight(1f).height(20.dp),
+ thumb = {
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape)
+ )
+ },
+ track = { sliderState ->
+ 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(2.dp)
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
+ RoundedCornerShape(1.dp)
+ ),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction)
+ .height(2.dp)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ RoundedCornerShape(1.dp)
+ )
+ )
+ }
+ }
+ )
+ IconButton(
+ onClick = { pitch = ((pitch * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(2.0f); saveAndApply() },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
}
- },
- onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
- valueRange = 0.5f..2.0f,
- steps = 14,
- modifier = Modifier.weight(1f).height(24.dp)
- )
- IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
- Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_pitch), modifier = Modifier.size(16.dp))
}
}
}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
index b9b7031..ed92a6f 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
@@ -123,7 +123,9 @@ fun VerticalScrollbar(
if (viewportRatio >= 1f) return@derivedStateOf null
- val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
+ val maxThumbHeight = viewportHeight / 2f
+ val minThumbHeight = minOf(80f, maxThumbHeight)
+ val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index 8651533..df01d07 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -19,7 +19,7 @@
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
- "UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
+ "UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
)
package com.aryan.reader.epubreader
@@ -35,6 +35,8 @@ import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
+import android.view.RoundedCorner
+import android.view.View
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
@@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageShader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -134,6 +141,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
@@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
+import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
+import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
+import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
+import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId
+import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
@@ -180,11 +193,11 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
+import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
-import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -218,12 +231,50 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
+private const val TOOL_ORDER_KEY = "reader_tool_order"
+private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
+private fun View.bottomRoundedCornerRadiusPx(): Int {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
+
+ val insets = rootWindowInsets ?: return 0
+ return max(
+ insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
+ insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
+ )
+}
+
+@Composable
+private fun rememberBottomRoundedCornerPadding(view: View): Dp {
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+ var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
+
+ DisposableEffect(
+ view,
+ configuration.orientation,
+ configuration.screenWidthDp,
+ configuration.screenHeightDp
+ ) {
+ val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
+ radiusPx = updatedView.bottomRoundedCornerRadiusPx()
+ }
+ view.addOnLayoutChangeListener(listener)
+ radiusPx = view.bottomRoundedCornerRadiusPx()
+
+ onDispose {
+ view.removeOnLayoutChangeListener(listener)
+ }
+ }
+
+ return with(density) { radiusPx.toDp() }
+}
+
private fun saveHiddenTools(context: Context, hiddenTools: Set) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
@@ -234,6 +285,34 @@ private fun loadHiddenTools(context: Context): Set {
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
}
+private fun saveToolOrder(context: Context, toolOrder: List) {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
+}
+
+private fun loadToolOrder(context: Context): List {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
+ ?.split(',')
+ ?.filter { it.isNotBlank() }
+ ?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
+ .orEmpty()
+ return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
+}
+
+private fun saveBottomTools(context: Context, bottomTools: Set) {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
+}
+
+private fun loadBottomTools(context: Context): Set {
+ val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ return prefs.getStringSet(
+ BOTTOM_TOOLS_KEY,
+ ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
+ ) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
+}
+
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
@@ -338,7 +417,7 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean {
- @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
+ @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
@@ -381,6 +460,7 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
const val PREF_READER_THEME = "reader_theme_id"
const val PREF_CUSTOM_THEMES = "custom_themes_json"
+@UnstableApi
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@@ -416,8 +496,8 @@ fun EpubReaderScreen(
}
} else null
- val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) {
- epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists()
+ val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
+ epubBook.hasReadableExtractedContent()
}
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
mutableStateOf(false)
@@ -563,6 +643,7 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
+ var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
@@ -582,7 +663,7 @@ fun EpubReaderHost(
var currentTtsMode by remember {
mutableStateOf(
loadTtsMode(context).let {
- if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
+ if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
}
)
}
@@ -738,18 +819,19 @@ fun EpubReaderHost(
}
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
+ var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
+ var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
- val isOss = BuildConfig.FLAVOR == "oss"
- val effectiveUseOnline = !isOss && useOnlineDictionary
+ val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(word)
- if (wordCount > 1 && !isProUser) {
+ if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = word
@@ -815,6 +897,8 @@ fun EpubReaderHost(
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
+ val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
var bookmarks by remember(epubBook.title) {
mutableStateOf(
@@ -991,6 +1075,7 @@ fun EpubReaderHost(
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
+ var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) }
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
@@ -1006,14 +1091,14 @@ fun EpubReaderHost(
var showFontSelectionSheet by remember { mutableStateOf(false) }
val fontSheetState = rememberModalBottomSheetState()
- LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
+ LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
if (isFormatLocal) {
saveLocalReaderSettings(
- context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
+ context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
)
} else {
saveReaderSettings(
- context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
+ context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
)
}
}
@@ -1130,6 +1215,7 @@ fun EpubReaderHost(
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
+ var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
val activeTheme = remember(currentThemeId, customThemes) {
BuiltInThemes.find { it.id == currentThemeId }
@@ -1151,6 +1237,19 @@ fun EpubReaderHost(
} else activeTheme.textColor
}
val activeTextureId = activeTheme.textureId
+ val activeTextureAlpha = 1f - globalTextureTransparency
+ val activeTextureBitmap = remember(activeTextureId) {
+ loadReaderTextureBitmap(context, activeTextureId)
+ }
+ val activeTextureModifier = activeTextureBitmap?.let { bitmap ->
+ Modifier.drawBehind {
+ drawRect(
+ brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)),
+ blendMode = BlendMode.SrcOver,
+ alpha = activeTextureAlpha.coerceIn(0f, 1f)
+ )
+ }
+ } ?: Modifier
val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
@@ -1401,7 +1500,7 @@ fun EpubReaderHost(
}
fun startTts() {
- if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+ if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@@ -1441,6 +1540,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterIndex,
+ totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@@ -1460,7 +1560,7 @@ fun EpubReaderHost(
)
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
- if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+ if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@@ -1504,6 +1604,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterIndex,
+ totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@@ -1582,18 +1683,6 @@ fun EpubReaderHost(
focusRequester = searchFocusRequester
)
- if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
- Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
- )
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text(
- "Error: Book content not found. Path: ${epubBook.extractionBasePath}",
- color = MaterialTheme.colorScheme.error
- )
- }
- return
- }
-
val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
if (currentClientHeightValue > 0) {
max(
@@ -1989,10 +2078,7 @@ fun EpubReaderHost(
}
}
- val pageInfoBottomPadding by animateDpAsState(
- targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
- label = "PageInfoBottomPadding"
- )
+ val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
val isPageInfoVisible = when (pageInfoMode) {
PageInfoMode.DEFAULT -> !showBars
@@ -2631,7 +2717,7 @@ fun EpubReaderHost(
}
val handleGenerateSummary: (Boolean) -> Unit = { force ->
- if (!isProUser && credits <= 0) {
+ if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
@@ -2688,6 +2774,7 @@ fun EpubReaderHost(
val finalSummaryBuilder = StringBuilder()
summarizeBookContent(
content = text,
+ context = context,
authToken = token,
onUsageReceived = { cost, freeRemaining ->
currentCost = cost
@@ -2750,7 +2837,7 @@ fun EpubReaderHost(
}
val handleGenerateRecap: () -> Unit = {
- if (credits <= 0) {
+ if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
@@ -2820,6 +2907,7 @@ fun EpubReaderHost(
modifier = Modifier
.fillMaxSize()
.background(effectiveBg)
+ .then(activeTextureModifier)
.padding(top = effectiveTopPadding)
.focusRequester(containerFocusRequester)
.focusable()
@@ -2879,13 +2967,15 @@ fun EpubReaderHost(
) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
- val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
+ val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
+ val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
+ val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
Box(
modifier = Modifier
.fillMaxSize()
+ .padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding)
- .padding(top = 16.dp)
.testTag("ReaderContainer")
) {
if (chapters.isEmpty()) {
@@ -3292,10 +3382,12 @@ fun EpubReaderHost(
currentParagraphGap = currentParagraphGap,
currentImageSize = currentImageSize,
currentHorizontalMargin = currentHorizontalMargin,
+ currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
+ activeTextureAlpha = activeTextureAlpha,
onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis()
showBars = false
@@ -3462,7 +3554,7 @@ fun EpubReaderHost(
if (ttsChunks.isNotEmpty()) {
logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
- if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+ if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false
return@launch
@@ -3483,6 +3575,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = targetChapterIndex,
+ totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@@ -3528,6 +3621,7 @@ fun EpubReaderHost(
summarizeBookContent(
content = content,
+ context = context,
authToken = token,
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
currentCost = cost
@@ -3779,11 +3873,14 @@ fun EpubReaderHost(
}
RenderMode.PAGINATED -> {
- val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
+ val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
+ val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
+ val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
+ .padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding)
.testTag("ReaderContainer")
) {
@@ -3799,6 +3896,7 @@ fun EpubReaderHost(
paragraphGapMultiplier = currentParagraphGap,
imageSizeMultiplier = currentImageSize,
horizontalMarginMultiplier = currentHorizontalMargin,
+ verticalMarginMultiplier = currentVerticalMargin,
fontFamily = activeFontFamily,
textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette,
@@ -3810,6 +3908,7 @@ fun EpubReaderHost(
offset = ttsState.startOffsetInSource
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
activeTextureId = activeTextureId,
+ activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
onPaginatorReady = { newPaginator ->
@@ -4094,17 +4193,19 @@ fun EpubReaderHost(
// Page Info Bar (Vertical)
AnimatedVisibility(
- visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
+ visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
- modifier = Modifier.align(Alignment.BottomCenter)
+ modifier = Modifier.align(
+ if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
+ )
) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(PAGE_INFO_BAR_HEIGHT)
+ .height(pageInfoBarHeight)
.background(infoBarBgColor)
- .padding(bottom = bottomPadding + pageInfoBottomPadding)
+ .then(activeTextureModifier)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
@@ -4140,17 +4241,19 @@ fun EpubReaderHost(
// Page Info Bar (Paginated)
AnimatedVisibility(
- visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
+ visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
- modifier = Modifier.align(Alignment.BottomCenter)
+ modifier = Modifier.align(
+ if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
+ )
) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(PAGE_INFO_BAR_HEIGHT)
+ .height(pageInfoBarHeight)
.background(infoBarBgColor)
- .padding(bottom = bottomPadding + pageInfoBottomPadding)
+ .then(activeTextureModifier)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
@@ -4442,6 +4545,8 @@ fun EpubReaderHost(
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
hiddenTools = hiddenTools,
+ toolOrder = toolOrder,
+ bottomTools = bottomTools,
onCustomizeTools = { showCustomizeToolsSheet = true },
onNavigateBack = { triggerSaveAndExit() },
isKeepScreenOn = isKeepScreenOn,
@@ -4522,6 +4627,71 @@ fun EpubReaderHost(
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
+ onOpenAiHub = { showAiHubSheet = true },
+ onOpenSlider = {
+ when (currentRenderMode) {
+ RenderMode.VERTICAL_SCROLL -> {
+ sliderStartPage = currentPageInChapter
+ sliderCurrentPage = currentPageInChapter.toFloat()
+ isPageSliderVisible = true
+ showBars = false
+ scope.launch {
+ webViewRefForTts?.let { webView ->
+ startPageThumbnail = captureWebViewVisibleArea(webView)
+ }
+ }
+ }
+ RenderMode.PAGINATED -> {
+ if (paginatedPagerState.pageCount > 0) {
+ sliderStartPage = paginatedPagerState.currentPage + 1
+ sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
+ isPageSliderVisible = true
+ showBars = false
+ startPageThumbnail = null
+ } else {
+ bannerMessage = BannerMessage("Book is not paginated yet.")
+ }
+ }
+ }
+ },
+ onOpenDrawer = {
+ scope.launch { drawerState.open() }
+ },
+ onToggleFormat = {
+ showFormatAdjustmentBars = !showFormatAdjustmentBars
+ if (showFormatAdjustmentBars) {
+ searchState.showSearchResultsPanel = false
+ isPageSliderVisible = false
+ }
+ },
+ onToggleSearch = {
+ searchState.isSearchActive = true
+ searchState.showSearchResultsPanel = true
+ showBars = true
+ showFormatAdjustmentBars = false
+ },
+ onToggleTts = {
+ if (isTtsSessionActive) {
+ Timber.d("TTS button clicked: Stopping TTS")
+ userStoppedTts = true
+ ttsController.stop()
+ } else {
+ when {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED -> {
+ startTts()
+ }
+ activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> {
+ showPermissionRationaleDialog = true
+ }
+ else -> {
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+ },
onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@@ -4687,8 +4857,12 @@ fun EpubReaderHost(
ttsState = ttsState,
isProUser = isProUser,
hiddenTools = hiddenTools,
+ toolOrder = toolOrder,
+ bottomTools = bottomTools,
currentTtsMode = currentTtsMode,
onOpenAiHub = { showAiHubSheet = true },
+ onOpenDictionarySettings = { showDictionarySettingsSheet = true },
+ onOpenThemeSettings = { showThemePanel = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@@ -4770,6 +4944,8 @@ fun EpubReaderHost(
onImageSizeChange = { currentImageSize = it },
currentHorizontalMargin = currentHorizontalMargin,
onHorizontalMarginChange = { currentHorizontalMargin = it },
+ currentVerticalMargin = currentVerticalMargin,
+ onVerticalMarginChange = { currentVerticalMargin = it },
currentFont = currentFontFamily,
currentCustomFontName = if(currentCustomFontPath != null) {
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
@@ -4788,6 +4964,7 @@ fun EpubReaderHost(
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
currentImageSize = DEFAULT_IMAGE_SIZE_VAL
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
+ currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
currentFontFamily = ReaderFont.ORIGINAL
currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT
@@ -5085,10 +5262,20 @@ fun EpubReaderHost(
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,
+ toolOrder = toolOrder,
+ bottomTools = bottomTools,
onUpdate = { newHiddenSet ->
hiddenTools = newHiddenSet
saveHiddenTools(context, newHiddenSet)
},
+ onOrderUpdate = { newOrder ->
+ toolOrder = newOrder
+ saveToolOrder(context, newOrder)
+ },
+ onPlacementUpdate = { newBottomTools ->
+ bottomTools = newBottomTools
+ saveBottomTools(context, newBottomTools)
+ },
onDismiss = { showCustomizeToolsSheet = false }
)
}
@@ -5133,6 +5320,11 @@ fun EpubReaderHost(
pageInfoMode = it
savePageInfoMode(context, it)
},
+ pageInfoPosition = pageInfoPosition,
+ onPageInfoPositionChange = {
+ pageInfoPosition = it
+ savePageInfoPosition(context, it)
+ },
pullToTurnEnabled = pullToTurnEnabled,
onPullToTurnChange = {
pullToTurnEnabled = it
@@ -5173,6 +5365,11 @@ fun EpubReaderHost(
ReaderThemePanel(
isVisible = true,
currentThemeId = currentThemeId,
+ globalTextureTransparency = globalTextureTransparency,
+ onGlobalTextureTransparencyChange = {
+ globalTextureTransparency = it
+ saveGlobalTextureTransparency(context, it)
+ },
onThemeSelected = {
currentThemeId = it
saveReaderThemeId(context, it)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
index 80fcbcf..bad04ba 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
@@ -120,6 +120,7 @@ private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
+private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
@@ -127,6 +128,7 @@ const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
+const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch"
@@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
HIDDEN(2, "Always Hide")
}
+enum class PageInfoPosition(val id: Int, val title: String) {
+ BOTTOM(0, "Bottom"),
+ TOP(1, "Top")
+}
+
data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
+ val verticalMargin: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
@@ -194,9 +202,11 @@ private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
+private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
+private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
+ verticalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
+ putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else {
@@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
}
+fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
+ val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
+}
+
+fun loadPageInfoPosition(context: Context): PageInfoPosition {
+ val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
+ return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
+}
+
fun savePullToTurn(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
@@ -288,6 +311,11 @@ fun loadHorizontalMargin(context: Context): Float {
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
}
+fun loadVerticalMargin(context: Context): Float {
+ val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
+}
+
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@@ -321,6 +349,12 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
loadHorizontalMargin(context)
}
+ val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
+ prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
+ } else {
+ loadVerticalMargin(context)
+ }
+
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else {
@@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
paragraphGap = paragraphGap,
imageSize = imageSize,
horizontalMargin = horizontalMargin,
+ verticalMargin = verticalMargin,
font = font,
customPath = customPath,
textAlign = textAlign
@@ -390,6 +425,7 @@ fun saveReaderSettings(
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
+ verticalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@@ -401,6 +437,7 @@ fun saveReaderSettings(
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
+ putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
@@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit,
+ currentVerticalMargin: Float,
+ onVerticalMarginChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
@@ -699,6 +738,20 @@ fun ReaderTextFormatPanel(
}
}
)
+
+ FormatSlider(
+ label = stringResource(R.string.label_vertical_margin),
+ value = currentVerticalMargin,
+ onValueChange = onVerticalMarginChange,
+ valueRange = 0.0f..3.0f,
+ formatValue = {
+ when {
+ it <= 0.01f -> noneLabel
+ it in 0.99f..1.01f -> originalLabel
+ else -> "%.1fx".format(it)
+ }
+ }
+ )
}
}
}
@@ -830,6 +883,8 @@ fun VisualOptionsSheet(
onSystemUiModeChange: (SystemUiMode) -> Unit,
pageInfoMode: PageInfoMode,
onPageInfoModeChange: (PageInfoMode) -> Unit,
+ pageInfoPosition: PageInfoPosition,
+ onPageInfoPositionChange: (PageInfoPosition) -> Unit,
pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
@@ -884,6 +939,16 @@ fun VisualOptionsSheet(
getLabel = { it.title }
)
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
+ Spacer(modifier = Modifier.height(8.dp))
+ OptionSegmentedControl(
+ options = PageInfoPosition.entries,
+ selectedOption = pageInfoPosition,
+ onOptionSelected = onPageInfoPositionChange,
+ getLabel = { it.title }
+ )
+
Spacer(modifier = Modifier.height(24.dp))
// Pull to change chapter
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
index 0164118..42ce113 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
@@ -20,6 +20,7 @@
package com.aryan.reader.epubreader
import timber.log.Timber
+import android.graphics.Color
import android.view.KeyEvent
import android.view.View
import android.view.Window
@@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
return@DisposableEffect onDispose {}
}
val insetsController = WindowCompat.getInsetsController(window, view)
+ val originalStatusBarColor = window.statusBarColor
+ val originalNavigationBarColor = window.navigationBarColor
Timber.d("Applying immersive mode.")
WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
Timber.d("Restoring system UI.")
WindowCompat.setDecorFitsSystemWindows(window, true)
+ window.statusBarColor = originalStatusBarColor
+ window.navigationBarColor = originalNavigationBarColor
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
insetsController.systemBarsBehavior = initialSystemBarsBehavior
@@ -142,4 +149,4 @@ fun Modifier.volumeScrollHandler(
}
}
true
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
index 0c5082d..6808cf3 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
@@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex,
+ totalChapters = chapters.size,
+ continueSession = true,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx,
+ totalChapters = chapters.size,
+ continueSession = true,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterToTry,
+ totalChapters = chapters.size,
+ continueSession = true,
ttsMode = ttsMode,
playbackSource = "READER",
authToken = token
diff --git a/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt b/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt
new file mode 100644
index 0000000..bf71e09
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt
@@ -0,0 +1,172 @@
+package com.aryan.reader.feedback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+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.foundation.layout.width
+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.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.outlined.FavoriteBorder
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import com.aryan.reader.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SupportProjectScreen(
+ navController: NavHostController
+) {
+ val uriHandler = LocalUriHandler.current
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.support_project_title)) },
+ navigationIcon = {
+ IconButton(onClick = { navController.popBackStack() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Icon(
+ imageVector = Icons.Outlined.FavoriteBorder,
+ contentDescription = null,
+ modifier = Modifier.size(72.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.support_project_heading),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(R.string.support_project_desc),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ SupportOptionCard(
+ title = stringResource(R.string.support_github_sponsor),
+ description = stringResource(R.string.support_github_sponsor_desc),
+ icon = {
+ Icon(
+ painter = painterResource(id = R.drawable.github),
+ contentDescription = stringResource(R.string.support_github_sponsor),
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = {
+ uriHandler.openUri("https://github.com/sponsors/Aryan-Raj3112")
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ SupportOptionCard(
+ title = stringResource(R.string.support_patreon),
+ description = stringResource(R.string.support_patreon_desc),
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.FavoriteBorder,
+ contentDescription = stringResource(R.string.support_patreon),
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = {
+ uriHandler.openUri("https://www.patreon.com/c/epistemereader")
+ }
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun SupportOptionCard(
+ title: String,
+ description: String,
+ icon: @Composable () -> Unit,
+ onClick: () -> Unit
+) {
+ OutlinedCard(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ icon()
+ Spacer(modifier = Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+ contentDescription = stringResource(R.string.action_open),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
index 15747ac..500a86c 100644
--- a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
@@ -38,6 +38,8 @@ data class OpdsAcquisition(
get() = when {
mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF"
+ mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
+ mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
@@ -52,6 +54,7 @@ data class OpdsAcquisition(
"PDF" -> 4
"MOBI" -> 3
"FB2" -> 2
+ "MD", "HTML" -> 2
"CBZ" -> 1
"TXT" -> 0
else -> -1
@@ -90,4 +93,4 @@ data class OpdsEntry(
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
index 303ed2d..a8fdb05 100644
--- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import com.aryan.reader.resolveFileExtensionSuffixFromName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import okhttp3.Response
import okhttp3.Request
import timber.log.Timber
import java.io.File
@@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
val body = response.body ?: throw Exception("Empty body")
val contentLength = body.contentLength()
- val ext = when (acquisition.formatName) {
- "EPUB" -> ".epub"
- "PDF" -> ".pdf"
- "MOBI" -> ".mobi"
- "FB2" -> ".fb2"
- "CBZ" -> ".cbz"
- "CBR" -> ".cbr"
- "TXT" -> ".txt"
- else -> ".epub"
- }
+ val ext = resolveOpdsDownloadExtension(acquisition, response)
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
@@ -148,6 +141,45 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
+ val candidates = listOfNotNull(
+ response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
+ Uri.parse(acquisition.url).lastPathSegment
+ )
+
+ candidates.forEach { candidate ->
+ resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
+ }
+
+ return when (acquisition.formatName) {
+ "EPUB" -> ".epub"
+ "PDF" -> ".pdf"
+ "MOBI" -> ".mobi"
+ "FB2" -> ".fb2"
+ "CBZ" -> ".cbz"
+ "CBR" -> ".cbr"
+ "MD" -> ".md"
+ "HTML" -> ".html"
+ "TXT" -> ".txt"
+ else -> ".epub"
+ }
+ }
+
+ private fun extractContentDispositionFilename(contentDisposition: String): String? {
+ val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
+ .find(contentDisposition)
+ ?.groupValues
+ ?.getOrNull(1)
+ if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
+
+ return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
+ .find(contentDisposition)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.trim()
+ ?.trim('"')
+ }
+
init {
loadCatalogs()
}
@@ -221,4 +253,4 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt
new file mode 100644
index 0000000..a069fec
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt
@@ -0,0 +1,81 @@
+package com.aryan.reader.paginatedreader
+
+import android.graphics.BitmapFactory
+import androidx.compose.ui.text.font.FontFamily
+import java.io.File
+import java.net.URLDecoder
+import java.nio.file.Paths
+
+object AndroidHtmlResourceResolver : HtmlResourceResolver {
+ override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
+ if (src.isBlank()) return null
+ val decodedSrc = try {
+ URLDecoder.decode(src, "UTF-8")
+ } catch (_: Exception) {
+ src
+ }
+ val parentPath = File(chapterAbsPath).parent ?: ""
+ val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
+ val fromRelativeFile = File(extractionBasePath, relativePath)
+
+ return try {
+ when {
+ fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
+ File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
+ else -> null
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ override fun readText(path: String): String? {
+ return runCatching { File(path).readText() }.getOrNull()
+ }
+
+ override fun imageDimensions(path: String): Pair? {
+ return runCatching {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(path, options)
+ if (options.outWidth > 0 && options.outHeight > 0) {
+ options.outWidth.toFloat() to options.outHeight.toFloat()
+ } else {
+ null
+ }
+ }.getOrNull()
+ }
+}
+
+object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
+ override fun load(fontFaces: List, extractionBasePath: String): Map {
+ return loadFontFamilies(fontFaces, extractionBasePath)
+ }
+}
+
+fun androidHtmlToSemanticBlocks(
+ html: String,
+ cssRules: OptimizedCssRules,
+ textStyle: androidx.compose.ui.text.TextStyle,
+ chapterAbsPath: String,
+ extractionBasePath: String,
+ density: androidx.compose.ui.unit.Density,
+ fontFamilyMap: Map,
+ constraints: androidx.compose.ui.unit.Constraints,
+ imageDimensionsCache: Map> = emptyMap(),
+ mathSvgCache: Map = emptyMap()
+): List {
+ return htmlToSemanticBlocks(
+ html = html,
+ cssRules = cssRules,
+ textStyle = textStyle,
+ chapterAbsPath = chapterAbsPath,
+ extractionBasePath = extractionBasePath,
+ density = density,
+ fontFamilyMap = fontFamilyMap,
+ constraints = constraints,
+ imageDimensionsCache = imageDimensionsCache,
+ mathSvgCache = mathSvgCache,
+ resourceResolver = AndroidHtmlResourceResolver,
+ fontFamilyLoader = AndroidHtmlFontFamilyLoader
+ )
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
index 2ea88bb..8133d68 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
@@ -120,7 +120,8 @@ class BookPaginator(
private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float,
- private val imageSizeMultiplier: Float
+ private val imageSizeMultiplier: Float,
+ private val verticalMarginMultiplier: Float
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@@ -185,6 +186,14 @@ class BookPaginator(
isLoading = true
Timber.d("Initialization started.")
+ if (chapters.isEmpty()) {
+ totalPageCount = 0
+ pageCountsAreAccurate = true
+ isLoading = false
+ Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
+ return@launch
+ }
+
// 1. Book processing check (Keep existing logic)
val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
@@ -214,7 +223,7 @@ class BookPaginator(
// 5. Prioritize CURRENT chapter only
// We no longer blindly queue neighbors immediately to keep startup fast.
// We only queue the requested chapter.
- val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1)
+ val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
// Trigger actual pagination for the current chapter to replace the estimate with reality
triggerPagination(startChapter, PRIORITY_HIGHEST)
@@ -277,6 +286,7 @@ class BookPaginator(
append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
+ append("-vm:$verticalMarginMultiplier")
}
val hash = configString.hashCode()
return hash
@@ -503,7 +513,7 @@ class BookPaginator(
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
- val semanticBlocks = htmlToSemanticBlocks(
+ val semanticBlocks = androidHtmlToSemanticBlocks(
html = processedHtml,
cssRules = parsingCssRules,
textStyle = textStyle.copy(color = Color.Black),
@@ -792,6 +802,10 @@ class BookPaginator(
}
private fun triggerPagination(chapterIndex: Int, priority: Int) {
+ if (chapterIndex !in chapters.indices) {
+ Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
+ return
+ }
if (pageCache[chapterIndex] != null) {
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
return
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
index 3b50a40..69dd687 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
@@ -163,10 +163,12 @@ class ContentStyler(
}
is SemanticMath -> {
+ val svgContent = block.svgContent
+ val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
- block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
+ block.isFromMathJax || nonBlankSvgContent == null -> svgContent
else -> {
- val themedSvg = applyThemeToSvg(block.svgContent)
+ val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg)
}
}
@@ -452,11 +454,11 @@ class ContentStyler(
}
}
- if (span.linkHref != null) {
- addStringAnnotation("URL", span.linkHref, span.start, span.end)
+ span.linkHref?.let { linkHref ->
+ addStringAnnotation("URL", linkHref, span.start, span.end)
}
- if (span.elementId != null) {
- addStringAnnotation("ID", span.elementId, span.start, span.end)
+ span.elementId?.let { elementId ->
+ addStringAnnotation("ID", elementId, span.start, span.end)
}
}
}
@@ -589,4 +591,4 @@ class ContentStyler(
else -> if (isOrdered) "$counter. " else "• "
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
index dc49af1..ae1f204 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
@@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
import java.io.File
import java.security.MessageDigest
-/**
- * A centralized mapper for handling conversions between generic CSS font family names
- * and Compose's FontFamily objects.
- */
-object FontFamilyMapper {
- private val genericFontMap = mapOf(
- "serif" to FontFamily.Serif,
- "sans-serif" to FontFamily.SansSerif,
- "monospace" to FontFamily.Monospace,
- "cursive" to FontFamily.Cursive,
- "default" to FontFamily.Default,
- "system-ui" to FontFamily.Default,
- "ui-sans-serif" to FontFamily.Default,
- "ui-serif" to FontFamily.Default,
- "ui-monospace" to FontFamily.Default,
- "ui-rounded" to FontFamily.Default
- )
-
- /**
- * Converts a string name (e.g., "serif") to a Compose [FontFamily].
- */
- fun nameToFontFamily(name: String): FontFamily? {
- return genericFontMap[name.trim().lowercase()]
- }
-
- /**
- * Converts a Compose [FontFamily] back to its primary string name for serialization.
- * Custom fonts are not serialized by name and will return null.
- */
- fun fontFamilyToName(fontFamily: FontFamily): String? {
- return when (fontFamily) {
- FontFamily.Serif -> "serif"
- FontFamily.SansSerif -> "sans-serif"
- FontFamily.Monospace -> "monospace"
- FontFamily.Cursive -> "cursive"
- FontFamily.Default -> "default"
- else -> null
- }
- }
-}
-
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
val identifier = "$bookId:$fontPath"
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
@@ -158,4 +117,4 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
null
}
}.filterValues { it != null }.mapValues { it.value!! }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
index 6b1e545..55bd8c6 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
@@ -115,7 +115,7 @@ class LocatorConverter(
otherComplex = mergedOtherComplex
)
- val semanticBlocks = htmlToSemanticBlocks(
+ val semanticBlocks = androidHtmlToSemanticBlocks(
html = htmlToParse,
cssRules = parsingCssRules,
textStyle = TextStyle(),
@@ -381,4 +381,4 @@ class LocatorConverter(
}
return@withContext null
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
index e673629..ce064a8 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -147,7 +148,7 @@ import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest.Builder
import com.aryan.reader.R
-import com.aryan.reader.ReaderTexture
+import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor
@@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
else -> 1.0f
}
+private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
+
+private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
+ return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
+}
+
private fun createHeaderTextStyle(
baseStyle: TextStyle,
level: Int,
@@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
return with(density) { widthPx.toDp() to heightPx.toDp() }
}
+private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
+ return when {
+ style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
+ style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
+ else -> Alignment.Center
+ }
+}
+
+private fun tableCellImageModifier(
+ block: ImageBlock,
+ density: Density,
+ imageSizeMultiplier: Float
+): Modifier {
+ val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
+ Modifier.width(block.style.width * imageSizeMultiplier)
+ } else {
+ Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
+ }
+
+ val intrinsicWidth = block.intrinsicWidth
+ val intrinsicHeight = block.intrinsicHeight
+ val sizedModifier = if (
+ intrinsicWidth != null &&
+ intrinsicHeight != null &&
+ intrinsicWidth > 0f &&
+ intrinsicHeight > 0f
+ ) {
+ baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
+ } else {
+ baseModifier.height(
+ if (block.expectedHeight > 0) {
+ with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
+ } else {
+ 250.dp
+ }
+ )
+ }
+
+ return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
+ sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
+ } else {
+ sizedModifier
+ }
+}
+
@Composable
private fun WrappingContentLayout(
block: WrappingContentBlock,
@@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
paragraphGapMultiplier: Float,
imageSizeMultiplier: Float,
horizontalMarginMultiplier: Float,
+ verticalMarginMultiplier: Float,
fontFamily: FontFamily,
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
@@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List,
onUpdatePalette: (Int, HighlightColor) -> Unit,
- activeTextureId: String? = null
+ activeTextureId: String? = null,
+ activeTextureAlpha: Float = 0.55f
) {
LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
val context = LocalContext.current
val textureBitmap = remember(activeTextureId) {
- activeTextureId?.let { id ->
- ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
- ImageBitmap.imageResource(context.resources, resId)
- }
- }
+ loadReaderTextureBitmap(context, activeTextureId)
}
val textureModifier = if (textureBitmap != null) {
@@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
val brush = ShaderBrush(
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
)
- drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
+ drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
}
} else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) }
- BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
+ BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer()
val baseTextStyle = MaterialTheme.typography.bodyLarge
@@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
+ var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
@@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
debouncedFontFamily
) {
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
- val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
+ val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy(
color = effectiveText,
@@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
}
}
- LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) {
+ LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult ||
lineHeightMultiplier != debouncedLineHeightMult ||
paragraphGapMultiplier != debouncedParagraphGapMult ||
imageSizeMultiplier != debouncedImageSizeMult ||
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
+ verticalMarginMultiplier != debouncedVerticalMarginMult ||
fontFamily != debouncedFontFamily ||
textAlign != debouncedTextAlign
) {
@@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
debouncedParagraphGapMult = paragraphGapMultiplier
debouncedImageSizeMult = imageSizeMultiplier
debouncedHorizontalMarginMult = horizontalMarginMultiplier
+ debouncedVerticalMarginMult = verticalMarginMultiplier
debouncedFontFamily = fontFamily
debouncedTextAlign = textAlign
Timber.d("Debounce complete. Applying new format settings.")
@@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
}
val density = LocalDensity.current
- val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
- val verticalPadding = 16.dp
+ val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
+ val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
+ val effectiveReaderPadding =
+ remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
+ val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
+ val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
+ val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
+ .coerceAtMost(this.constraints.maxWidth)
+ val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
+ .coerceAtMost(this.constraints.maxHeight)
+ val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
+ ((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
+ )
+ val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
+ ((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
+ )
+ with(density) {
+ horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
+ }
+ }
+ val horizontalPadding = effectiveReaderPadding.first
+ val verticalPadding = effectiveReaderPadding.second
val textConstraints =
remember(this.constraints, density, horizontalPadding, verticalPadding) {
@@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
val finalConstraints = this.constraints.copy(
minWidth = 0,
- maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
+ maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
minHeight = 0,
- maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
+ maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
)
finalConstraints
}
@@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
mathMLRenderer = mathMLRenderer,
userTextAlign = userTextAlign,
paragraphGapMultiplier = debouncedParagraphGapMult,
- imageSizeMultiplier = debouncedImageSizeMult
+ imageSizeMultiplier = debouncedImageSizeMult,
+ verticalMarginMultiplier = debouncedVerticalMarginMult
)
}
@@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette,
- effectiveText = effectiveText
+ effectiveText = effectiveText,
+ pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
+ pageTextureBitmap = textureBitmap,
+ pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
)
androidx.compose.animation.AnimatedVisibility(
@@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List