->
if (uris.isNotEmpty()) {
- Timber.d("${uris.size} file(s) selected.")
+ val fileLabel = if (uris.size == 1) "file" else "files"
+ Timber.d("${uris.size} $fileLabel selected.")
onFilesSelected(uris)
} else {
Timber.d("File selection cancelled.")
@@ -383,6 +385,7 @@ fun DeleteConfirmationDialog(
@Composable
fun FileInfoDialog(
item: RecentFileItem,
+ usePdfFileNameAsDisplayName: Boolean = false,
onDismiss: () -> Unit,
onSaveMetadata: (BookMetadataEdit) -> Unit,
onSaveDisplayName: (String?) -> Unit,
@@ -399,8 +402,8 @@ fun FileInfoDialog(
mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty())
}
var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) }
- var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) {
- mutableStateOf(item.customName ?: item.cardTitle())
+ var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName, usePdfFileNameAsDisplayName) {
+ mutableStateOf(item.customName ?: item.cardTitle(usePdfFileNameAsDisplayName))
}
var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) }
@@ -455,7 +458,7 @@ fun FileInfoDialog(
} else {
stringResource(R.string.file_information)
},
- subtitle = item.cardTitle(),
+ subtitle = item.cardTitle(usePdfFileNameAsDisplayName),
onClose = {
if (isEditing) {
isEditing = false
@@ -498,6 +501,7 @@ fun FileInfoDialog(
} else {
BookMetadataInfoContent(
item = item,
+ usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
formattedDate = formattedDate,
lastModifiedDate = lastModifiedDate,
pathText = pathTextFinal,
@@ -611,6 +615,7 @@ private fun FileInfoTopBar(
@Composable
private fun BookMetadataInfoContent(
item: RecentFileItem,
+ usePdfFileNameAsDisplayName: Boolean,
formattedDate: String,
lastModifiedDate: String?,
pathText: String,
@@ -624,7 +629,7 @@ private fun BookMetadataInfoContent(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
- item.cardTitle(),
+ item.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
maxLines = 3,
@@ -1120,6 +1125,8 @@ private fun Double.formatMetadataNumber(): String {
@Composable
fun CustomTopBanner(bannerMessage: BannerMessage?) {
+ val context = LocalContext.current
+ val bannerText = bannerMessage?.localizedMessage(context).orEmpty()
AnimatedVisibility(
visible = bannerMessage != null,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
@@ -1138,7 +1145,7 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
shadowElevation = 8.dp
) {
Text(
- text = bannerMessage?.message ?: "",
+ text = bannerText,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.bodyMedium,
@@ -1149,6 +1156,25 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
}
}
+private fun BannerMessage.localizedMessage(context: Context): String {
+ return text?.resolveAndroidText(context) ?: message
+}
+
+private fun SharedText.resolveAndroidText(context: Context): String {
+ val resources = context.resources
+ val packageName = context.packageName
+ val formatArgs = args.toTypedArray()
+ val quantityValue = quantity
+ val resolved = if (quantityValue == null) {
+ val id = resources.getIdentifier(name, "string", packageName)
+ if (id == 0) null else runCatching { resources.getString(id, *formatArgs) }.getOrNull()
+ } else {
+ val id = resources.getIdentifier(name, "plurals", packageName)
+ if (id == 0) null else runCatching { resources.getQuantityString(id, quantityValue, *formatArgs) }.getOrNull()
+ }
+ return resolved ?: fallbackMessage()
+}
+
@Suppress("KotlinConstantConditions")
@Composable
fun AboutDialog(onDismiss: () -> Unit) {
@@ -1429,7 +1455,12 @@ fun AutoSizeText(
}
@Composable
-fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
+fun FileTypeBadge(
+ type: FileType,
+ modifier: Modifier = Modifier,
+ overlay: Boolean = false,
+ compact: Boolean = false
+) {
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
@@ -1442,9 +1473,17 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
) {
Text(
text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(),
- style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
+ style = if (compact) {
+ MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, letterSpacing = 0.sp)
+ } else {
+ MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp)
+ },
fontWeight = FontWeight.ExtraBold,
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
+ maxLines = 1,
+ modifier = Modifier.padding(
+ horizontal = if (compact) 6.dp else 10.dp,
+ vertical = if (compact) 3.dp else 4.dp
+ )
)
}
}
@@ -1498,8 +1537,12 @@ fun BookTagChipsRow(
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
-fun RecentFileItem.cardTitle(): String {
- return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName
+fun RecentFileItem.cardTitle(usePdfFileNameAsDisplayName: Boolean = false): String {
+ customName?.takeIf { it.isNotBlank() }?.let { return it }
+ if (usePdfFileNameAsDisplayName && type == FileType.PDF) {
+ return displayName
+ }
+ return title?.takeIf { it.isNotBlank() } ?: displayName
}
fun RecentFileItem.cardAuthor(): String {
diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
index abc0c11..fa6c19e 100644
--- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
+++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
@@ -5,31 +5,34 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
-import com.aryan.reader.shared.AddBooksSource as SharedAddBooksSource
-import com.aryan.reader.shared.AppContrastOption as SharedAppContrastOption
-import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
-import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
-import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
-import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
-import com.aryan.reader.shared.RenderMode as SharedRenderMode
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord
-import com.aryan.reader.shared.ShelfType as SharedShelfType
-import com.aryan.reader.shared.SortOrder as SharedSortOrder
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
+fun FileType.toSharedFileType(): SharedFileType = this
+
+fun SharedFileType.toAndroidFileType(): FileType = this
+
+fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters = this
+
+fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters = this
+
+fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
+
+fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
+
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
- type = type.toSharedFileType(),
+ type = type,
displayName = customName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
@@ -67,7 +70,7 @@ fun SharedBookItem.toRecentFileItem(
return androidBooksById[id]?.copy(tags = resolvedTags)
?.copy(
uriString = path,
- type = type.toAndroidFileType(),
+ type = type,
displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
@@ -92,7 +95,7 @@ fun SharedBookItem.toRecentFileItem(
?: RecentFileItem(
bookId = id,
uriString = path,
- type = type.toAndroidFileType(),
+ type = type,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
@@ -169,11 +172,11 @@ fun ReaderScreenState.toSharedReaderScreenState(
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
- selectedFileType = selectedFileType?.toSharedFileType(),
+ selectedFileType = selectedFileType,
isLoading = isLoading,
errorMessage = errorMessage,
- renderMode = renderMode.toSharedRenderMode(),
- sortOrder = sortOrder.toSharedSortOrder(),
+ renderMode = renderMode,
+ sortOrder = sortOrder,
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = isAddingBooksToShelf,
showCreateShelfDialog = showCreateShelfDialog,
@@ -181,7 +184,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
libraryScreenStartPage = libraryScreenStartPage,
showRenameShelfDialogFor = showRenameShelfDialogFor,
showDeleteShelfDialogFor = showDeleteShelfDialogFor,
- addBooksSource = addBooksSource.toSharedAddBooksSource(),
+ addBooksSource = addBooksSource,
booksSelectedForAdding = booksSelectedForAdding,
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
selectedShelfIds = contextualActionShelfIds,
@@ -189,10 +192,10 @@ fun ReaderScreenState.toSharedReaderScreenState(
credits = credits,
isSyncEnabled = isSyncEnabled,
isFolderSyncEnabled = isFolderSyncEnabled,
- bannerMessage = bannerMessage?.toSharedBannerMessage(),
+ bannerMessage = bannerMessage,
downloadingBookIds = downloadingBookIds,
uploadingBookIds = uploadingBookIds,
- syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() },
+ syncedFolders = syncedFolders,
lastFolderScanTime = lastFolderScanTime,
hasUnreadFeedback = hasUnreadFeedback,
searchQuery = searchQuery,
@@ -204,7 +207,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
- libraryFilters = libraryFilters.toSharedLibraryFilters(),
+ libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
@@ -213,12 +216,14 @@ fun ReaderScreenState.toSharedReaderScreenState(
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
useStrictFileFilter = useStrictFileFilter,
- appThemeMode = appThemeMode.toSharedAppThemeMode(),
- appContrastOption = appContrastOption.toSharedAppContrastOption(),
+ usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
+ appThemeMode = appThemeMode,
+ appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
- customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() },
+ appFontPreference = appFontPreference,
+ customAppThemes = customAppThemes,
allTags = dbTags.map { it.toSharedTag() },
showTagSelectionDialogFor = showTagSelectionDialogFor
)
@@ -271,7 +276,7 @@ fun SharedShelf.toAndroidShelf(
return Shelf(
id = id,
name = name,
- type = type.toAndroidShelfType(),
+ type = type,
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
parentShelfId = parentShelfId,
@@ -280,91 +285,3 @@ fun SharedShelf.toAndroidShelf(
sortKey = sortKey
)
}
-
-fun FileType.toSharedFileType(): SharedFileType {
- return this
-}
-
-fun SharedFileType.toAndroidFileType(): FileType {
- return this
-}
-
-fun RenderMode.toSharedRenderMode(): SharedRenderMode {
- return this
-}
-
-fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
- return this
-}
-
-fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
- return this
-}
-
-fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
- return this
-}
-
-fun SortOrder.toSharedSortOrder(): SharedSortOrder {
- return this
-}
-
-fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
- return this
-}
-
-fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
- return this
-}
-
-fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
- return this
-}
-
-fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
- return this
-}
-
-fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
- return this
-}
-
-fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
- return this
-}
-
-fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
- return this
-}
-
-private fun SharedShelfType.toAndroidShelfType(): ShelfType {
- return ShelfType.valueOf(name)
-}
-
-fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
- return this
-}
-
-fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
- return this
-}
-
-fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
- return this
-}
-
-fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
- return this
-}
-
-fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
- return this
-}
-
-fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
- return this
-}
-
-fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
- return this
-}
diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
index 260890c..7d5a7a0 100644
--- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
+++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt
@@ -2,7 +2,7 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
-import org.json.JSONObject
+import com.aryan.reader.shared.SharedFolderBookMetadata
data class FolderBookMetadata(
val bookId: String,
@@ -31,67 +31,76 @@ data class FolderBookMetadata(
val originalDescription: String? = null
) {
fun toJsonString(): String {
- val json = JSONObject()
- json.put("bookId", bookId)
- json.put("displayName", displayName)
- json.put("type", type)
- json.put("lastChapterIndex", lastChapterIndex ?: -1)
- json.put("lastPage", lastPage ?: -1)
- json.put("lastPositionCfi", lastPositionCfi)
- json.put("progressPercentage", progressPercentage.toDouble())
- json.put("isRecent", isRecent)
- json.put("lastModifiedTimestamp", lastModifiedTimestamp)
- json.put("bookmarksJson", bookmarksJson)
- json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
- json.put("locatorCharOffset", locatorCharOffset ?: -1)
- json.put("customName", customName)
- json.put("highlightsJson", highlightsJson)
- return json.toString()
+ return toSharedFolderBookMetadata().toJsonString()
}
companion object {
fun fromJsonString(jsonString: String): FolderBookMetadata {
- val json = JSONObject(jsonString)
-
- fun JSONObject.optStringNull(key: String): String? {
- return if (has(key) && !isNull(key)) getString(key) else null
- }
-
- fun JSONObject.optIntNull(key: String): Int? {
- val value = optInt(key, -1)
- return if (value == -1) null else value
- }
-
- return FolderBookMetadata(
- bookId = json.getString("bookId"),
- title = null,
- author = null,
- displayName = json.optString("displayName", "Unknown"),
- type = json.optString("type", "PDF"),
- lastChapterIndex = json.optIntNull("lastChapterIndex"),
- lastPage = json.optIntNull("lastPage"),
- lastPositionCfi = json.optStringNull("lastPositionCfi"),
- progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
- isRecent = json.optBoolean("isRecent", true),
- lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
- bookmarksJson = json.optStringNull("bookmarksJson"),
- locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
- locatorCharOffset = json.optIntNull("locatorCharOffset"),
- customName = json.optStringNull("customName"),
- highlightsJson = json.optStringNull("highlightsJson"),
- seriesName = null,
- seriesIndex = null,
- description = null,
- originalTitle = null,
- originalAuthor = null,
- originalSeriesName = null,
- originalSeriesIndex = null,
- originalDescription = null
- )
+ return SharedFolderBookMetadata.fromJsonString(jsonString)
+ ?.toFolderBookMetadata()
+ ?: error("Invalid folder metadata JSON")
}
}
}
+fun FolderBookMetadata.toSharedFolderBookMetadata(): SharedFolderBookMetadata {
+ return SharedFolderBookMetadata(
+ bookId = bookId,
+ title = title,
+ author = author,
+ displayName = displayName,
+ type = type,
+ lastChapterIndex = lastChapterIndex,
+ lastPage = lastPage,
+ lastPositionCfi = lastPositionCfi,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ lastModifiedTimestamp = lastModifiedTimestamp,
+ bookmarksJson = bookmarksJson,
+ locatorBlockIndex = locatorBlockIndex,
+ locatorCharOffset = locatorCharOffset,
+ customName = customName,
+ highlightsJson = highlightsJson,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription
+ )
+}
+
+fun SharedFolderBookMetadata.toFolderBookMetadata(): FolderBookMetadata {
+ return FolderBookMetadata(
+ bookId = bookId,
+ title = title,
+ author = author,
+ displayName = displayName,
+ type = type,
+ lastChapterIndex = lastChapterIndex,
+ lastPage = lastPage,
+ lastPositionCfi = lastPositionCfi,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ lastModifiedTimestamp = lastModifiedTimestamp,
+ bookmarksJson = bookmarksJson,
+ locatorBlockIndex = locatorBlockIndex,
+ locatorCharOffset = locatorCharOffset,
+ customName = customName,
+ highlightsJson = highlightsJson,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription
+ )
+}
+
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
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 3b5be54..ad07f81 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt
@@ -53,6 +53,10 @@ data class EpubBook(
val description: String? = null,
)
+fun epubContentFilePath(path: String): String = path.substringBefore('#').substringBefore('?')
+
+fun EpubChapter.contentFilePath(): String = epubContentFilePath(htmlFilePath)
+
fun EpubBook.hasReadableExtractedContent(): Boolean {
if (extractionBasePath.isBlank()) return false
val extractionDir = File(extractionBasePath)
@@ -60,6 +64,6 @@ fun EpubBook.hasReadableExtractedContent(): Boolean {
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
return chapters.all { chapter ->
- File(extractionDir, chapter.htmlFilePath).isFile
+ File(extractionDir, chapter.contentFilePath()).isFile
}
}
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 155377c..0d6c679 100644
--- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
+++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
@@ -52,6 +52,11 @@ class SingleFileImporter(private val context: Context) {
companion object {
private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
+ private const val MAX_HTML_CHAPTER_CHARS = 1_000_000
+ private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
+ private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
+ private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
+ private const val PAGE_BREAK_MARKER = ""
}
private val htmlSafelist = Safelist.relaxed()
@@ -519,57 +524,140 @@ class SingleFileImporter(private val context: Context) {
var pageNum = 1
val headBuilder = java.lang.StringBuilder()
val currentChapterBuilder = java.lang.StringBuilder()
+ var titleFound = false
+ var authorFound = false
- var line: String?
- while (reader.readLine().also { line = it } != null) {
- val trimmed = line!!.trim()
+ fun appendCss(style: String) {
+ if (style.isBlank() || cssBuilder.length >= MAX_HTML_INLINE_CSS_CHARS) return
+ val remaining = MAX_HTML_INLINE_CSS_CHARS - cssBuilder.length
+ cssBuilder.append(style, 0, minOf(style.length, remaining)).append('\n')
+ }
+
+ fun appendHeadSample(sample: String) {
+ if (headBuilder.length >= MAX_HTML_HEAD_SCAN_CHARS) return
+ val remaining = MAX_HTML_HEAD_SCAN_CHARS - headBuilder.length
+ headBuilder.append(sample, 0, minOf(sample.length, remaining)).append('\n')
+ }
+
+ fun flushChapter() {
+ if (currentChapterBuilder.isBlank()) {
+ currentChapterBuilder.clear()
+ return
+ }
+ chapters.add(
+ writeHtmlChapter(
+ extractionDir,
+ bookId,
+ pageNum++,
+ title,
+ cssBuilder.toString(),
+ currentChapterBuilder.toString()
+ )
+ )
+ currentChapterBuilder.clear()
+ }
+
+ fun appendBodySegment(segment: String, startIndex: Int = 0, endIndex: Int = segment.length, addNewline: Boolean = true) {
+ var start = startIndex
+ while (start < endIndex) {
+ val remainingCapacity = (MAX_HTML_CHAPTER_CHARS - currentChapterBuilder.length).coerceAtLeast(1)
+ val requestedEnd = minOf(endIndex, start + remainingCapacity)
+ val chunkEnd = findHtmlChunkEnd(segment, start, requestedEnd, endIndex)
+ currentChapterBuilder.append(segment, start, chunkEnd)
+ start = chunkEnd
+ if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
+ flushChapter()
+ }
+ }
+ if (addNewline) {
+ currentChapterBuilder.append('\n')
+ if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
+ flushChapter()
+ }
+ }
+ }
+
+ fun appendBodyLine(line: String) {
+ var start = 0
+ var markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
+ while (markerIndex >= 0) {
+ appendBodySegment(line, start, markerIndex)
+ flushChapter()
+ start = markerIndex + PAGE_BREAK_MARKER.length
+ markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
+ }
+ appendBodySegment(line, start, line.length)
+ }
+
+ reader.forEachBoundedLine(MAX_HTML_BUFFERED_LINE_CHARS) { line ->
+ val trimmed = line.trim()
if (inScript) {
if (trimmed.contains("").substringBefore("")
- if (styleContent.isNotBlank()) cssBuilder.append(styleContent).append("\n")
+ appendCss(styleContent)
if (trimmed.contains("")) {
inStyle = false
}
- continue
+ return@forEachBoundedLine
}
if (inStyle) {
if (trimmed.contains("")) {
- cssBuilder.append(line.substringBefore("")).append("\n")
+ appendCss(line.substringBefore(""))
inStyle = false
} else {
- cssBuilder.append(line).append("\n")
+ appendCss(line)
}
- continue
+ return@forEachBoundedLine
}
if (trimmed.equals("", ignoreCase = true)) {
inBody = true
- continue
+ return@forEachBoundedLine
}
if (trimmed.startsWith("", "")
- if (afterBody.isNotBlank()) currentChapterBuilder.append(afterBody).append("\n")
- continue
+ if (afterBody.isNotBlank()) appendBodyLine(afterBody)
+ return@forEachBoundedLine
+ }
+ val embeddedBodyIndex = line.indexOf("= 0) {
+ val bodyContentIndex = line.indexOf('>', startIndex = embeddedBodyIndex)
+ if (bodyContentIndex >= 0) {
+ inBody = true
+ val afterBody = line.substring(bodyContentIndex + 1)
+ if (afterBody.isNotBlank()) appendBodyLine(afterBody)
+ return@forEachBoundedLine
+ }
}
if (trimmed.startsWith("") ||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("", ignoreCase = true) || trimmed.equals("", ignoreCase = true)) {
- continue
+ return@forEachBoundedLine
}
- if (line.contains("")) {
- val parts = line.split("")
- for (i in parts.indices) {
- currentChapterBuilder.append(parts[i]).append("\n")
- if (i < parts.size - 1) {
- val chapterHtml = currentChapterBuilder.toString()
- if (chapterHtml.isNotBlank()) {
- chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), chapterHtml))
- }
- currentChapterBuilder.clear() // Clean memory allocation
- }
- }
- continue
- }
-
- currentChapterBuilder.append(line).append("\n")
-
- if (currentChapterBuilder.length > 2_000_000) {
- chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), currentChapterBuilder.toString()))
- currentChapterBuilder.clear()
- }
+ appendBodyLine(line)
}
}
- val finalChapterHtml = currentChapterBuilder.toString()
- if (finalChapterHtml.isNotBlank()) {
- chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), finalChapterHtml))
- }
+ flushChapter()
}
if (chapters.isEmpty()) {
@@ -761,7 +826,7 @@ class SingleFileImporter(private val context: Context) {
FileOutputStream(tempFile).bufferedWriter().use { writer ->
val title = originalBookNameHint.substringBeforeLast(".")
writer.write("\n\n
\n$title\n\n\n")
- writer.write(htmlContent)
+ writeHtmlBodyContentChunked(writer, htmlContent)
writer.write("\n\n")
}
@@ -816,12 +881,19 @@ class SingleFileImporter(private val context: Context) {
val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName)
val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent)
+ val escapedTitle = title.replace("\"", """)
- val fullHtml = "\n\n\n${title.replace("\"", """)}\n\n\n\n${sanitizedBodyContent.trim()}\n\n"
+ file.bufferedWriter().use { writer ->
+ writer.write("\n\n\n")
+ writer.write(escapedTitle)
+ writer.write("\n\n\n\n")
+ writer.write(sanitizedBodyContent.trim())
+ writer.write("\n\n")
+ }
- file.writeText(fullHtml)
-
- val plainText = Jsoup.parse(fullHtml).text()
+ val plainText = Jsoup.parse(sanitizedBodyContent).text()
return EpubChapter(
chapterId = "${bookId}_$pageNum",
@@ -834,4 +906,70 @@ class SingleFileImporter(private val context: Context) {
isInToc = true
)
}
+
+ private fun java.io.BufferedReader.forEachBoundedLine(
+ maxLineChars: Int,
+ onLine: (String) -> Unit
+ ) {
+ val buffer = CharArray(16_384)
+ val currentLine = StringBuilder()
+ while (true) {
+ val read = read(buffer)
+ if (read == -1) break
+
+ var start = 0
+ var index = 0
+ while (index < read) {
+ val char = buffer[index]
+ val reachesLimit = currentLine.length + (index - start + 1) >= maxLineChars
+ if (char == '\n' || reachesLimit) {
+ val count = index - start + if (char == '\n') 0 else 1
+ if (count > 0) {
+ currentLine.append(buffer, start, count)
+ }
+ onLine(currentLine.toString().trimEnd('\r'))
+ currentLine.clear()
+ start = index + 1
+ }
+ index++
+ }
+
+ if (start < read) {
+ currentLine.append(buffer, start, read - start)
+ }
+ }
+
+ if (currentLine.isNotEmpty()) {
+ onLine(currentLine.toString().trimEnd('\r'))
+ }
+ }
+
+ private fun findHtmlChunkEnd(
+ source: String,
+ start: Int,
+ requestedEnd: Int,
+ absoluteEnd: Int
+ ): Int {
+ if (requestedEnd >= absoluteEnd) return absoluteEnd
+
+ val tagStart = source.lastIndexOf('<', requestedEnd - 1)
+ val tagEnd = source.lastIndexOf('>', requestedEnd - 1)
+ if (tagStart > start && tagStart > tagEnd && requestedEnd - tagStart <= 4096) {
+ return tagStart
+ }
+
+ return requestedEnd
+ }
+
+ private fun writeHtmlBodyContentChunked(writer: java.io.Writer, htmlContent: String) {
+ var charsSinceBreak = 0
+ for (char in htmlContent) {
+ writer.write(char.code)
+ charsSinceBreak++
+ if (char == '>' || charsSinceBreak >= MAX_HTML_BUFFERED_LINE_CHARS) {
+ writer.write('\n'.code)
+ charsSinceBreak = 0
+ }
+ }
+ }
}
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 86d2353..34001a3 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -468,6 +468,9 @@ fun ChapterWebView(
ttsScope: CoroutineScope,
tocFragments: List,
initialFragmentId: String? = null,
+ initialImageSource: String? = null,
+ initialImageOriginalSource: String? = null,
+ initialImageOrdinal: Int = 0,
onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean,
isOss: Boolean = false,
@@ -1011,6 +1014,14 @@ fun ChapterWebView(
}
onChapterInitiallyScrolled()
scrollActionTaken = true
+ } else if (!initialImageSource.isNullOrBlank()) {
+ val imageJsCommand =
+ "javascript:window.scrollToReaderImageSource('${escapeJsString(initialImageSource)}', $initialImageOrdinal, '${escapeJsString(initialImageOriginalSource.orEmpty())}');"
+ Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to image source: $initialImageSource")
+ view?.evaluateJavascript(imageJsCommand) {
+ onChapterInitiallyScrolled()
+ scrollActionTaken = true
+ }
} else if (initialScrollTarget != null) {
val scrollJsCommand = when (initialScrollTarget) {
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
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 672a4e4..176176e 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
@@ -41,6 +41,7 @@ import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook
+import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.summarizationUrl
@@ -209,8 +210,7 @@ suspend fun executeRecapLogic(
val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) {
try {
val chapter = chapters[i]
- val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
- val doc = Jsoup.parse(File(fullPath), "UTF-8")
+ val doc = Jsoup.parse(File(epubBook.extractionBasePath, chapter.contentFilePath()), "UTF-8")
doc.body().text()
} catch (_: Exception) { "" }
}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
index 420c1d6..0e9d8c6 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
@@ -23,10 +23,13 @@ import android.content.Context
import com.aryan.reader.R
import timber.log.Timber
import com.aryan.reader.epub.EpubBook
+import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.LocatorConverter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import org.jsoup.nodes.Node
import java.io.File
data class ChapterLoadingResult(
@@ -34,9 +37,44 @@ data class ChapterLoadingResult(
val chunks: List,
val startChunkIndex: Int,
val isSuccess: Boolean,
- val errorMessage: String? = null
+ val errorMessage: String? = null,
+ val chunkElementStartIndices: List = emptyList(),
+ val chunkElementCounts: List = emptyList()
)
+internal data class ReaderHtmlChunk(
+ val html: String,
+ val elementStartIndex: Int,
+ val elementCount: Int
+)
+
+internal fun splitBodyNodesIntoReaderChunks(
+ bodyNodes: List,
+ chunkSize: Int = 20
+): List {
+ var elementStartIndex = 0
+ return bodyNodes.chunked(chunkSize).map { nodes ->
+ val elementCount = nodes.count { it is Element }
+ ReaderHtmlChunk(
+ html = nodes.joinToString(separator = "\n") { it.outerHtml() },
+ elementStartIndex = elementStartIndex,
+ elementCount = elementCount
+ ).also {
+ elementStartIndex += elementCount
+ }
+ }
+}
+
+internal fun readerChunkContainerAttributes(
+ index: Int,
+ chunkElementStartIndices: List,
+ chunkElementCounts: List
+): String {
+ val startIndex = chunkElementStartIndices.getOrElse(index) { index * 20 }
+ val elementCount = chunkElementCounts.getOrElse(index) { 20 }
+ return "data-chunk-index='$index' data-element-start-index='$startIndex' data-element-count='$elementCount'"
+}
+
/**
* loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc.).
@@ -56,24 +94,36 @@ suspend fun loadChapterContent(
)
try {
- val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
- val htmlFile = File(fullPath)
+ val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
- val (headContent, chunks) = if (htmlFile.exists()) {
+ val (headContent, chunks, chunkElementStartIndices, chunkElementCounts) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
doc.select("script").remove()
val bodyNodes = doc.body().childNodes().toList()
- val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
- chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }
- }
- if (chunkedList.isEmpty()) {
- head to listOf("${context.getString(R.string.chapter_empty)}
")
+ val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
+ if (htmlChunks.isEmpty()) {
+ ChapterHtmlPayload(
+ head = head,
+ chunks = listOf("${context.getString(R.string.chapter_empty)}
"),
+ chunkElementStartIndices = listOf(0),
+ chunkElementCounts = listOf(1)
+ )
} else {
- head to chunkedList
+ ChapterHtmlPayload(
+ head = head,
+ chunks = htmlChunks.map { it.html },
+ chunkElementStartIndices = htmlChunks.map { it.elementStartIndex },
+ chunkElementCounts = htmlChunks.map { it.elementCount }
+ )
}
} else {
- "" to listOf("${context.getString(R.string.chapter_not_found)}
")
+ ChapterHtmlPayload(
+ head = "",
+ chunks = listOf("${context.getString(R.string.chapter_not_found)}
"),
+ chunkElementStartIndices = listOf(0),
+ chunkElementCounts = listOf(1)
+ )
}
var targetChunk = 0
@@ -101,7 +151,9 @@ suspend fun loadChapterContent(
head = headContent,
chunks = chunks,
startChunkIndex = targetChunk,
- isSuccess = true
+ isSuccess = true,
+ chunkElementStartIndices = chunkElementStartIndices,
+ chunkElementCounts = chunkElementCounts
)
} catch (e: Exception) {
@@ -114,4 +166,11 @@ suspend fun loadChapterContent(
errorMessage = e.message
)
}
-}
\ No newline at end of file
+}
+
+private data class ChapterHtmlPayload(
+ val head: String,
+ val chunks: List,
+ val chunkElementStartIndices: List,
+ val chunkElementCounts: List
+)
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 a99801d..c3799da 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
@@ -52,7 +52,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
-import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -62,7 +61,6 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -83,6 +81,7 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
@@ -92,6 +91,8 @@ import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator
@@ -119,6 +120,7 @@ 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.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
@@ -157,12 +159,14 @@ import kotlin.math.roundToInt
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
THEME(R.string.tooltip_theme_desc, "Top Bar"),
+ BRIGHTNESS(R.string.tool_brightness, "Top Bar"),
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
TOC(R.string.tool_sidebar, "Bottom Bar"),
FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"),
SEARCH(R.string.action_search, "Bottom Bar"),
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
+ FILE_INFO(R.string.file_information, "Overflow Menu"),
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
@@ -259,6 +263,7 @@ class DragDropState(
private val epubToolbarTools = setOf(
ReaderTool.DICTIONARY,
ReaderTool.THEME,
+ ReaderTool.BRIGHTNESS,
ReaderTool.SLIDER,
ReaderTool.TOC,
ReaderTool.FORMAT,
@@ -268,7 +273,57 @@ private val epubToolbarTools = setOf(
ReaderTool.SCREEN_ORIENTATION
)
-internal fun defaultReaderHiddenTools(): Set = setOf(ReaderTool.SCREEN_ORIENTATION.name)
+internal enum class EpubOverflowMenuSection {
+ CUSTOMIZE_TOOLBAR,
+ HIDDEN_TOOLS,
+ VIEW_ORIGINAL_PDF,
+ DELETE_TEXT_VIEW,
+ READING_MODE,
+ BOOKMARK,
+ TAP_TO_TURN,
+ VOLUME_SCROLL,
+ PAGE_TURN_ANIM,
+ KEEP_SCREEN_ON,
+ VISUAL_OPTIONS,
+ AUTO_SCROLL,
+ TTS_SETTINGS,
+ FILE_INFO
+}
+
+internal fun epubOverflowMenuSections(
+ hiddenTools: Set,
+ hasHiddenToolbarTools: Boolean,
+ hasToggleReflow: Boolean,
+ hasDeleteReflow: Boolean,
+ hasFileInfo: Boolean = true
+): List = buildList {
+ add(EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR)
+ if (hasHiddenToolbarTools) add(EpubOverflowMenuSection.HIDDEN_TOOLS)
+ if (hasToggleReflow) add(EpubOverflowMenuSection.VIEW_ORIGINAL_PDF)
+ if (hasDeleteReflow) add(EpubOverflowMenuSection.DELETE_TEXT_VIEW)
+ if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) add(EpubOverflowMenuSection.READING_MODE)
+ if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) add(EpubOverflowMenuSection.BOOKMARK)
+ if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) add(EpubOverflowMenuSection.TAP_TO_TURN)
+ if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) add(EpubOverflowMenuSection.VOLUME_SCROLL)
+ if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) add(EpubOverflowMenuSection.PAGE_TURN_ANIM)
+ if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
+ if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
+ if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
+ if (
+ !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
+ !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
+ ) {
+ add(EpubOverflowMenuSection.TTS_SETTINGS)
+ }
+ if (hasFileInfo && !hiddenTools.contains(ReaderTool.FILE_INFO.name)) {
+ add(EpubOverflowMenuSection.FILE_INFO)
+ }
+}
+
+internal fun defaultReaderHiddenTools(): Set = setOf(
+ ReaderTool.SCREEN_ORIENTATION.name,
+ ReaderTool.BRIGHTNESS.name
+)
internal fun defaultReaderToolOrder(): List = ReaderTool.entries.toList()
@@ -321,6 +376,7 @@ fun EpubReaderTopBar(
currentRenderMode: RenderMode,
isBookmarked: Boolean,
isTtsActive: Boolean,
+ isSliderActive: Boolean,
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
@@ -340,6 +396,7 @@ fun EpubReaderTopBar(
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
+ onOpenBrightness: () -> Unit,
onOpenVisualOptions: () -> Unit,
onOpenScreenOrientation: () -> Unit,
onOpenSlider: () -> Unit,
@@ -348,6 +405,7 @@ fun EpubReaderTopBar(
onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit,
onToggleTts: () -> Unit,
+ onOpenFileInfo: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
hiddenTools: Set,
toolOrder: List,
@@ -420,13 +478,23 @@ fun EpubReaderTopBar(
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
+ ReaderTool.BRIGHTNESS -> TooltipIconButton(
+ text = stringResource(R.string.reader_brightness_title),
+ description = stringResource(R.string.reader_brightness_system_desc),
+ onClick = onOpenBrightness
+ ) {
+ Icon(painter = painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title))
+ }
ReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
- onClick = onOpenSlider,
- enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+ onClick = onOpenSlider
) {
- Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
+ Icon(
+ painter = painterResource(id = R.drawable.slider),
+ contentDescription = stringResource(R.string.content_desc_navigate_slider),
+ tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
}
ReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
@@ -511,318 +579,330 @@ fun EpubReaderTopBar(
}
) {
val hiddenToolbarTools = toolOrder.filter { it in epubToolbarTools && hiddenTools.contains(it.name) }
- DropdownMenuItem(
- text = { Text(stringResource(R.string.title_customize_toolbar)) },
- onClick = {
- showMoreMenu = false
- onCustomizeTools()
- },
- leadingIcon = {
- Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
- }
- )
- HorizontalDivider()
-
- if (hiddenToolbarTools.isNotEmpty()) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
- 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,
- onOpenScreenOrientation = onOpenScreenOrientation
- )
- }
- }
- HorizontalDivider()
- }
-
- if (onToggleReflow != null) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_view_original_pdf)) },
- onClick = {
- showMoreMenu = false
- onToggleReflow()
- },
- leadingIcon = {
- Icon(
- painter = painterResource(id = R.drawable.picture_as_pdf),
- contentDescription = null,
- modifier = Modifier.size(20.dp)
- )
- }
- )
- HorizontalDivider()
- }
-
- onDeleteReflow?.let {
- HorizontalDivider()
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_delete_text_view)) },
- onClick = {
- showMoreMenu = false
- it()
- },
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.Delete,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error
- )
- },
- colors = androidx.compose.material3.MenuDefaults.itemColors(
- textColor = MaterialTheme.colorScheme.error
- )
- )
- }
-
- if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_change_reading_mode)) },
- onClick = { showReadingModeExpanded = !showReadingModeExpanded },
- trailingIcon = {
- Icon(
- Icons.Default.ArrowDropDown,
- contentDescription = null,
- modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
- )
- }
- )
- if (showReadingModeExpanded) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
- enabled = !isTtsActive,
- onClick = {
- showMoreMenu = false
- onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
- },
- trailingIcon = {
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_selected)
- )
- })
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
- enabled = !isTtsActive,
- onClick = {
- onSetRightToLeftPagination(false)
- showMoreMenu = false
- onChangeRenderMode(RenderMode.PAGINATED)
- },
- trailingIcon = {
- if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
- Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_selected)
- )
- }
- })
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
- enabled = !isTtsActive,
- onClick = {
- onSetRightToLeftPagination(true)
- showMoreMenu = false
- onChangeRenderMode(RenderMode.PAGINATED)
- },
- trailingIcon = {
- if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
- Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_selected)
- )
- }
- })
- }
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
- DropdownMenuItem(text = {
- Text(
- if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
- R.string.menu_bookmark_this_page
- )
- )
- }, onClick = {
- showMoreMenu = false
- onToggleBookmark()
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
- enabled = currentRenderMode == RenderMode.PAGINATED,
- onClick = {
- onToggleTapToNavigate(!tapToNavigateEnabled)
- showMoreMenu = false
- },
- trailingIcon = {
- if (tapToNavigateEnabled) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_enabled)
- )
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
- DropdownMenuItem(
- text = {
- Text(
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(
- R.string.menu_volume_button_scrolling
- )
- else stringResource(R.string.menu_volume_button_page_turn)
- )
- },
- enabled = true,
- onClick = {
- onToggleVolumeScroll(!volumeScrollEnabled)
- showMoreMenu = false
- },
- trailingIcon = {
- if (volumeScrollEnabled) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_enabled)
- )
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
- enabled = currentRenderMode == RenderMode.PAGINATED,
- onClick = {
- onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
- showMoreMenu = false
- },
- trailingIcon = {
- if (isPageTurnAnimationEnabled) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_enabled)
- )
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_keep_screen_on)) },
- onClick = {
- onToggleKeepScreenOn(!isKeepScreenOn)
- showMoreMenu = false
- },
- trailingIcon = {
- if (isKeepScreenOn) Icon(
- Icons.Default.Check,
- contentDescription = stringResource(R.string.content_desc_enabled)
- )
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_visual_options)) },
- onClick = {
- showMoreMenu = false
- onOpenVisualOptions()
- },
- leadingIcon = {
- Icon(
- Icons.Default.Visibility,
- contentDescription = null,
- modifier = Modifier.size(20.dp)
- )
- })
- HorizontalDivider()
- }
- if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_auto_scroll)) },
- enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
- onClick = {
- showMoreMenu = false
- onStartAutoScroll()
- })
- HorizontalDivider()
- }
val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
- if (showTtsVoiceSettings || showTtsReplacements) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tts_settings)) },
- onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
- leadingIcon = {
- Icon(
- Icons.Default.GraphicEq,
- contentDescription = null,
- modifier = Modifier.size(20.dp)
- )
- },
- trailingIcon = {
- Icon(
- Icons.Default.ArrowDropDown,
- contentDescription = null,
- modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
- )
- }
- )
- if (showTtsSettingsExpanded) {
- if (showTtsVoiceSettings) {
+ epubOverflowMenuSections(
+ hiddenTools = hiddenTools,
+ hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
+ hasToggleReflow = onToggleReflow != null,
+ hasDeleteReflow = onDeleteReflow != null
+ ).forEachIndexed { index, section ->
+ if (index > 0) HorizontalDivider()
+ when (section) {
+ EpubOverflowMenuSection.CUSTOMIZE_TOOLBAR -> {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
- enabled = !isTtsActive,
+ text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = {
showMoreMenu = false
- onOpenTtsSettings()
+ onCustomizeTools()
+ },
+ leadingIcon = {
+ Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ )
+ }
+ EpubOverflowMenuSection.HIDDEN_TOOLS -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
+ 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,
+ isSliderActive = isSliderActive,
+ showMoreMenu = {
+ showHiddenToolsExpanded = false
+ showMoreMenu = false
+ },
+ onOpenDictionarySettings = onOpenDictionarySettings,
+ onOpenThemeSettings = onOpenThemeSettings,
+ onOpenBrightness = onOpenBrightness,
+ onOpenSlider = onOpenSlider,
+ onOpenDrawer = onOpenDrawer,
+ onToggleFormat = onToggleFormat,
+ onToggleSearch = onToggleSearch,
+ onOpenAiHub = onOpenAiHub,
+ onToggleTts = onToggleTts,
+ onOpenScreenOrientation = onOpenScreenOrientation
+ )
+ }
+ }
+ }
+ EpubOverflowMenuSection.FILE_INFO -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.file_information)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenFileInfo()
},
leadingIcon = {
Icon(
- Icons.Default.GraphicEq,
+ imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
- if (showTtsReplacements) {
+ EpubOverflowMenuSection.VIEW_ORIGINAL_PDF -> {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
+ text = { Text(stringResource(R.string.menu_view_original_pdf)) },
onClick = {
showMoreMenu = false
- onOpenTtsReplacements()
+ onToggleReflow?.invoke()
},
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.picture_as_pdf),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
+ EpubOverflowMenuSection.DELETE_TEXT_VIEW -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_delete_text_view)) },
+ onClick = {
+ showMoreMenu = false
+ onDeleteReflow?.invoke()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ colors = androidx.compose.material3.MenuDefaults.itemColors(
+ textColor = MaterialTheme.colorScheme.error
+ )
+ )
+ }
+ EpubOverflowMenuSection.READING_MODE -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_change_reading_mode)) },
+ onClick = { showReadingModeExpanded = !showReadingModeExpanded },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
+ )
+ }
+ )
+ if (showReadingModeExpanded) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
+ enabled = !isTtsActive,
+ onClick = {
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ })
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
+ enabled = !isTtsActive,
+ onClick = {
+ onSetRightToLeftPagination(false)
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.PAGINATED)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ }
+ })
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
+ enabled = !isTtsActive,
+ onClick = {
+ onSetRightToLeftPagination(true)
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.PAGINATED)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ }
+ })
+ }
+ }
+ EpubOverflowMenuSection.BOOKMARK -> {
+ DropdownMenuItem(text = {
+ Text(
+ if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
+ R.string.menu_bookmark_this_page
+ )
+ )
+ }, onClick = {
+ showMoreMenu = false
+ onToggleBookmark()
+ })
+ }
+ EpubOverflowMenuSection.TAP_TO_TURN -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
+ enabled = currentRenderMode == RenderMode.PAGINATED,
+ onClick = {
+ onToggleTapToNavigate(!tapToNavigateEnabled)
+ showMoreMenu = false
+ },
+ trailingIcon = {
+ if (tapToNavigateEnabled) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_enabled)
+ )
+ })
+ }
+ EpubOverflowMenuSection.VOLUME_SCROLL -> {
+ DropdownMenuItem(
+ text = {
+ Text(
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(
+ R.string.menu_volume_button_scrolling
+ )
+ else stringResource(R.string.menu_volume_button_page_turn)
+ )
+ },
+ enabled = true,
+ onClick = {
+ onToggleVolumeScroll(!volumeScrollEnabled)
+ showMoreMenu = false
+ },
+ trailingIcon = {
+ if (volumeScrollEnabled) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_enabled)
+ )
+ })
+ }
+ EpubOverflowMenuSection.PAGE_TURN_ANIM -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
+ enabled = currentRenderMode == RenderMode.PAGINATED,
+ onClick = {
+ onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
+ showMoreMenu = false
+ },
+ trailingIcon = {
+ if (isPageTurnAnimationEnabled) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_enabled)
+ )
+ })
+ }
+ EpubOverflowMenuSection.KEEP_SCREEN_ON -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_keep_screen_on)) },
+ onClick = {
+ onToggleKeepScreenOn(!isKeepScreenOn)
+ showMoreMenu = false
+ },
+ trailingIcon = {
+ if (isKeepScreenOn) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_enabled)
+ )
+ })
+ }
+ EpubOverflowMenuSection.VISUAL_OPTIONS -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_visual_options)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenVisualOptions()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Visibility,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ })
+ }
+ EpubOverflowMenuSection.AUTO_SCROLL -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_auto_scroll)) },
+ enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
+ onClick = {
+ showMoreMenu = false
+ onStartAutoScroll()
+ })
+ }
+ EpubOverflowMenuSection.TTS_SETTINGS -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_settings)) },
+ onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
+ },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
+ )
}
)
+ if (showTtsSettingsExpanded) {
+ if (showTtsVoiceSettings) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
+ enabled = !isTtsActive,
+ onClick = {
+ showMoreMenu = false
+ onOpenTtsSettings()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.GraphicEq,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
+ if (showTtsReplacements) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenTtsReplacements()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.GraphicEq,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
+ }
}
}
}
@@ -926,6 +1006,7 @@ fun EpubReaderBottomBar(
ttsState: TtsState,
isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
+ isSliderActive: Boolean,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
@@ -933,6 +1014,7 @@ fun EpubReaderBottomBar(
onOpenAiHub: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
+ onOpenBrightness: () -> Unit,
onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit,
hiddenTools: Set,
@@ -977,15 +1059,25 @@ fun EpubReaderBottomBar(
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
+ ReaderTool.BRIGHTNESS -> TooltipIconButton(
+ text = stringResource(R.string.reader_brightness_title),
+ description = stringResource(R.string.reader_brightness_system_desc),
+ onClick = onOpenBrightness
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.contrast),
+ contentDescription = stringResource(R.string.reader_brightness_title)
+ )
+ }
ReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
- onClick = onOpenSlider,
- enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+ onClick = onOpenSlider
) {
Icon(
painter = painterResource(id = R.drawable.slider),
- contentDescription = stringResource(R.string.content_desc_navigate_slider)
+ contentDescription = stringResource(R.string.content_desc_navigate_slider),
+ tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
ReaderTool.TOC -> TooltipIconButton(
@@ -1078,50 +1170,59 @@ fun EpubReaderPageSlider(
startPageThumbnail: Bitmap?,
paginator: IPaginator?,
chapters: List,
- onClose: () -> Unit,
onScrub: (Float) -> Unit,
- onJumpToPage: (Int) -> Unit
+ onJumpToPage: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ activeColor: Color = Color.Unspecified,
+ inactiveColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified,
+ thumbnailSurfaceColor: Color = Color.Unspecified,
+ thumbnailContentColor: Color = Color.Unspecified
) {
+ val effectiveActiveColor = if (activeColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ activeColor
+ }
+ val effectiveInactiveColor = if (inactiveColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.surfaceVariant
+ } else {
+ inactiveColor
+ }
+ val effectiveContentColor = if (contentColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ contentColor
+ }
+ val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.surfaceVariant
+ } else {
+ thumbnailSurfaceColor
+ }
+ val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ thumbnailContentColor
+ }
+
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
- exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200))
+ exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
+ modifier = modifier
) {
- Box(modifier = Modifier.fillMaxSize()) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) { onClose() }
- )
-
- IconButton(
- onClick = onClose,
- modifier = Modifier
- .align(Alignment.TopStart)
- .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
- .padding(8.dp)
- ) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.content_desc_exit_slider)
- )
- }
-
- // Bottom controls
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Spacer(Modifier.height(72.dp))
Box(
modifier = Modifier
.fillMaxWidth()
- .align(Alignment.BottomCenter)
- .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 32.dp, vertical = 16.dp),
+ .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = 32.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -1139,7 +1240,7 @@ fun EpubReaderPageSlider(
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
- color = MaterialTheme.colorScheme.primary,
+ color = effectiveActiveColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
@@ -1156,7 +1257,7 @@ fun EpubReaderPageSlider(
.fillMaxWidth()
.height(trackHeight)
.background(
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
+ color = effectiveInactiveColor,
shape = trackShape
)
) {
@@ -1165,7 +1266,7 @@ fun EpubReaderPageSlider(
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
- color = MaterialTheme.colorScheme.primary,
+ color = effectiveActiveColor,
shape = trackShape
)
)
@@ -1194,6 +1295,7 @@ fun EpubReaderPageSlider(
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
modifier = thumbnailModifier,
+ borderColor = effectiveActiveColor,
onClick = { onJumpToPage(sliderStartPage) }
) {
Image(
@@ -1213,11 +1315,14 @@ fun EpubReaderPageSlider(
}
ThumbnailWithIndicator(
modifier = thumbnailModifier,
+ borderColor = effectiveActiveColor,
onClick = { onJumpToPage(sliderStartPage) }
) {
PaginatedThumbnailContent(
pageNumber = sliderStartPage,
- chapterTitle = startPageChapterTitle
+ chapterTitle = startPageChapterTitle,
+ surfaceColor = effectiveThumbnailSurfaceColor,
+ contentColor = effectiveThumbnailContentColor
)
}
}
@@ -1226,7 +1331,7 @@ fun EpubReaderPageSlider(
Text(
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
+ color = effectiveContentColor,
fontSize = 18.sp
)
}
@@ -1271,8 +1376,17 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
}
@Composable
-internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) {
- val borderColor = MaterialTheme.colorScheme.primary
+internal fun ThumbnailWithIndicator(
+ modifier: Modifier = Modifier,
+ borderColor: Color = Color.Unspecified,
+ onClick: () -> Unit,
+ content: @Composable () -> Unit
+) {
+ val effectiveBorderColor = if (borderColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ borderColor
+ }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
@@ -1283,7 +1397,7 @@ internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
- border = BorderStroke(2.dp, borderColor)
+ border = BorderStroke(2.dp, effectiveBorderColor)
) {
content()
}
@@ -1292,17 +1406,32 @@ internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
- .background(borderColor)
+ .background(effectiveBorderColor)
)
}
}
@Composable
-private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) {
+private fun PaginatedThumbnailContent(
+ pageNumber: Int,
+ chapterTitle: String?,
+ surfaceColor: Color = Color.Unspecified,
+ contentColor: Color = Color.Unspecified
+) {
+ val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.surfaceVariant
+ } else {
+ surfaceColor
+ }
+ val effectiveContentColor = if (contentColor == Color.Unspecified) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ contentColor
+ }
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ color = effectiveSurfaceColor,
+ contentColor = effectiveContentColor
) {
Column(
modifier = Modifier.padding(4.dp),
@@ -2058,17 +2187,24 @@ enum class ToolbarSection(@StringRes val titleRes: Int) {
}
@Composable
-private fun ToolPreviewIcon(tool: ReaderTool) {
+private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
val title = stringResource(tool.titleRes)
when (tool) {
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
- ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
+ ReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp))
+ ReaderTool.SLIDER -> Icon(
+ painterResource(id = R.drawable.slider),
+ contentDescription = title,
+ modifier = Modifier.size(20.dp),
+ tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
+ ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
}
@@ -2077,11 +2213,11 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
@Composable
private fun HiddenEpubToolMenuItem(
tool: ReaderTool,
- currentRenderMode: RenderMode,
- isTtsActive: Boolean,
+ isSliderActive: Boolean,
showMoreMenu: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
+ onOpenBrightness: () -> Unit,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
@@ -2090,18 +2226,14 @@ private fun HiddenEpubToolMenuItem(
onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit
) {
- val enabled = when (tool) {
- ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
- else -> true
- }
DropdownMenuItem(
text = { Text(stringResource(tool.titleRes)) },
- enabled = enabled,
onClick = {
showMoreMenu()
when (tool) {
ReaderTool.DICTIONARY -> onOpenDictionarySettings()
ReaderTool.THEME -> onOpenThemeSettings()
+ ReaderTool.BRIGHTNESS -> onOpenBrightness()
ReaderTool.SLIDER -> onOpenSlider()
ReaderTool.TOC -> onOpenDrawer()
ReaderTool.FORMAT -> onToggleFormat()
@@ -2112,7 +2244,12 @@ private fun HiddenEpubToolMenuItem(
else -> Unit
}
},
- leadingIcon = { ToolPreviewIcon(tool) }
+ leadingIcon = { ToolPreviewIcon(tool, isSliderActive = isSliderActive) },
+ trailingIcon = if (tool == ReaderTool.SLIDER && isSliderActive) {
+ {
+ Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
+ }
+ } else null
)
}
@@ -2170,6 +2307,12 @@ fun TtsOverlayControls(
null
}
}
+ val canSkipPreviousChunk = !ttsState.isLoading &&
+ ttsState.currentChunkIndex > 0 &&
+ ttsState.totalChunks > 0
+ val canSkipNextChunk = !ttsState.isLoading &&
+ ttsState.currentChunkIndex >= 0 &&
+ ttsState.currentChunkIndex < ttsState.totalChunks - 1
val saveAndApply = {
saveTtsSpeechRate(context, rate)
@@ -2181,11 +2324,9 @@ fun TtsOverlayControls(
}
}
- val backgroundAlpha = 0.6f
-
Surface(
shape = RoundedCornerShape(28.dp),
- color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
@@ -2239,7 +2380,7 @@ fun TtsOverlayControls(
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
- color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f),
+ color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
@@ -2255,7 +2396,7 @@ fun TtsOverlayControls(
}
Surface(
- color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
+ color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp)
) {
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
@@ -2274,7 +2415,7 @@ fun TtsOverlayControls(
if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
Surface(
- color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f),
+ color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
@@ -2339,30 +2480,59 @@ fun TtsOverlayControls(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
- // Giant Play/Pause
- Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
- FilledIconButton(
- onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
- modifier = Modifier.size(56.dp),
- colors = IconButtonDefaults.filledIconButtonColors(
- containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
- contentColor = MaterialTheme.colorScheme.primary
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ enabled = canSkipPreviousChunk,
+ onClick = { ttsController.skipToPreviousChunk() },
+ modifier = Modifier.size(40.dp)
) {
Icon(
- painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
- stringResource(R.string.content_desc_play_pause),
- modifier = Modifier.size(28.dp)
+ imageVector = Icons.Default.SkipPrevious,
+ contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ // Giant Play/Pause
+ Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
+ FilledIconButton(
+ onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
+ modifier = Modifier.size(56.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
+ stringResource(R.string.content_desc_play_pause),
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ if (ttsState.isLoading) CircularProgressIndicator(
+ modifier = Modifier.size(56.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
+ strokeWidth = 3.dp
+ )
+ }
+
+ IconButton(
+ enabled = canSkipNextChunk,
+ onClick = { ttsController.skipToNextChunk() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipNext,
+ contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
+ modifier = Modifier.size(24.dp)
)
}
- if (ttsState.isLoading) CircularProgressIndicator(
- modifier = Modifier.size(56.dp),
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
- strokeWidth = 3.dp
- )
}
- Spacer(Modifier.width(16.dp))
+ Spacer(Modifier.width(12.dp))
// Unified Sliders Block
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.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 de87dba..9cc33de 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
@@ -19,9 +19,11 @@
*/
package com.aryan.reader.epubreader
+import android.graphics.BitmapFactory
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
@@ -32,6 +34,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -55,6 +58,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
@@ -66,12 +70,13 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -83,7 +88,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -94,8 +101,10 @@ import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import timber.log.Timber
@Composable
@@ -207,6 +216,7 @@ fun EpubReaderDrawerSheet(
chapters: List,
tableOfContents: List,
activeFragmentId: String?,
+ readerImages: List,
bookmarks: Set,
userHighlights: List,
currentChapterIndex: Int,
@@ -214,6 +224,8 @@ fun EpubReaderDrawerSheet(
renderMode: RenderMode,
onNavigateToChapter: (Int) -> Unit,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
+ onNavigateToImage: (EpubReaderImageReference) -> Unit,
+ onDownloadImage: (EpubReaderImageReference) -> Unit,
onNavigateToBookmark: (Bookmark) -> Unit,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit,
@@ -227,11 +239,15 @@ fun EpubReaderDrawerSheet(
ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
) {
- val drawerPagerState = rememberPagerState(pageCount = { 3 })
+ val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
- TabRow(selectedTabIndex = drawerPagerState.currentPage) {
+ ScrollableTabRow(
+ selectedTabIndex = drawerPagerState.currentPage,
+ edgePadding = 0.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
Tab(
selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
@@ -247,6 +263,11 @@ fun EpubReaderDrawerSheet(
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text(stringResource(R.string.tab_annotations)) }
)
+ Tab(
+ selected = drawerPagerState.currentPage == 3,
+ onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(3) } },
+ text = { Text(stringResource(R.string.tab_images)) }
+ )
}
HorizontalPager(
@@ -282,6 +303,11 @@ fun EpubReaderDrawerSheet(
onOpenPaletteManager = onOpenPaletteManager,
onHighlightColorChange = onHighlightColorChange
)
+ 3 -> ImagesList(
+ readerImages = readerImages,
+ onNavigateToImage = onNavigateToImage,
+ onDownloadImage = onDownloadImage
+ )
}
}
}
@@ -710,6 +736,145 @@ private fun BookmarksList(
}
}
+@Composable
+private fun ImagesList(
+ readerImages: List,
+ onNavigateToImage: (EpubReaderImageReference) -> Unit,
+ onDownloadImage: (EpubReaderImageReference) -> Unit
+) {
+ if (readerImages.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = stringResource(R.string.no_images_found),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center
+ )
+ }
+ return
+ }
+
+ val listState = rememberLazyListState()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(end = 4.dp),
+ contentPadding = PaddingValues(vertical = 4.dp)
+ ) {
+ items(
+ items = readerImages,
+ key = { it.id }
+ ) { image ->
+ ListItem(
+ leadingContent = {
+ EpubReaderImageThumbnail(
+ image = image,
+ modifier = Modifier.size(width = 72.dp, height = 56.dp)
+ )
+ },
+ headlineContent = {
+ Text(
+ text = image.displayTitle,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Column {
+ Text(
+ text = image.chapterTitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ val metadata = listOfNotNull(image.dimensionLabel, image.sourceName()).joinToString(" - ")
+ if (metadata.isNotBlank()) {
+ Text(
+ text = metadata,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ },
+ trailingContent = {
+ IconButton(onClick = { onDownloadImage(image) }) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = stringResource(R.string.content_desc_download_image)
+ )
+ }
+ },
+ modifier = Modifier.clickable { onNavigateToImage(image) }
+ )
+ HorizontalDivider()
+ }
+ }
+
+ VerticalScrollbar(
+ listState = listState,
+ modifier = Modifier.align(Alignment.CenterEnd)
+ )
+ }
+}
+
+@Composable
+private fun EpubReaderImageThumbnail(
+ image: EpubReaderImageReference,
+ modifier: Modifier = Modifier
+) {
+ var bitmap by remember(image.sourcePath) { mutableStateOf(null) }
+
+ LaunchedEffect(image.sourcePath) {
+ bitmap = withContext(Dispatchers.IO) {
+ if (image.sourcePath.startsWith("data:", ignoreCase = true)) {
+ val bytes = image.readDownloadBytes()
+ bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
+ } else {
+ BitmapFactory.decodeFile(image.sourcePath)
+ }
+ }
+ }
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(6.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
+ ) {
+ val currentBitmap = bitmap
+ if (currentBitmap != null) {
+ Image(
+ bitmap = currentBitmap.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = (image.index + 1).toString(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun HighlightsList(
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt
new file mode 100644
index 0000000..cab8d29
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderImages.kt
@@ -0,0 +1,243 @@
+package com.aryan.reader.epubreader
+
+import com.aryan.reader.epub.EpubBook
+import com.aryan.reader.epub.EpubChapter
+import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import java.io.File
+import java.net.URI
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+data class EpubReaderImageReference(
+ val id: String,
+ val index: Int,
+ val sourcePath: String,
+ val originalSource: String,
+ val altText: String?,
+ val chapterIndex: Int,
+ val chapterTitle: String,
+ val elementId: String?,
+ val ordinalInChapter: Int,
+ val chunkIndex: Int?,
+ val intrinsicWidth: Int?,
+ val intrinsicHeight: Int?
+) {
+ val displayTitle: String
+ get() = altText?.trim()?.takeIf { it.isNotBlank() }
+ ?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
+ ?: "Image ${index + 1}"
+
+ val dimensionLabel: String?
+ get() {
+ val width = intrinsicWidth?.takeIf { it > 0 }
+ val height = intrinsicHeight?.takeIf { it > 0 }
+ return if (width != null && height != null) "${width}x$height" else null
+ }
+
+ fun sourceName(): String? {
+ val source = originalSource.takeIf { it.isNotBlank() } ?: sourcePath
+ if (source.startsWith("data:", ignoreCase = true)) return null
+ return source
+ .substringBefore('#')
+ .substringBefore('?')
+ .replace('\\', '/')
+ .substringAfterLast('/')
+ .takeIf { it.isNotBlank() }
+ }
+
+ fun suggestedDownloadFileName(): String {
+ val extension = sourcePath.readerImageExtension()
+ ?: originalSource.readerImageExtension()
+ ?: "png"
+ val base = altText?.trim()?.takeIf { it.isNotBlank() }
+ ?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
+ ?: "image-${index + 1}"
+ val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
+ return "$safeBase.$extension"
+ }
+
+ fun mimeType(): String {
+ val dataMime = readerDataUriMimeType(sourcePath)
+ if (dataMime != null) return dataMime
+ return when (sourcePath.readerImageExtension() ?: originalSource.readerImageExtension()) {
+ "jpg", "jpeg" -> "image/jpeg"
+ "png" -> "image/png"
+ "gif" -> "image/gif"
+ "webp" -> "image/webp"
+ "bmp" -> "image/bmp"
+ "svg" -> "image/svg+xml"
+ else -> "image/*"
+ }
+ }
+}
+
+fun EpubBook.readerImageReferencesForDrawer(): List {
+ val references = mutableListOf()
+ chapters.forEachIndexed { chapterIndex, chapter ->
+ val html = chapter.readerImageHtml(extractionBasePath).takeIf { it.isNotBlank() }
+ ?: return@forEachIndexed
+ val sourceOrdinalByKey = mutableMapOf()
+ val document = Jsoup.parse(html, chapter.absPath)
+
+ document.select("img, image").forEach { element ->
+ val originalSource = element.readerImageSource() ?: return@forEach
+ val sourcePath = resolveReaderImageSource(chapter, extractionBasePath, originalSource)
+ val sourceKey = sourcePath.readerImageLookupKey()
+ val ordinal = sourceOrdinalByKey.getOrDefault(sourceKey, 0)
+ sourceOrdinalByKey[sourceKey] = ordinal + 1
+ val index = references.size
+
+ references += EpubReaderImageReference(
+ id = "android-epub-image:$chapterIndex:$ordinal:${sourcePath.hashCode()}:$index",
+ index = index,
+ sourcePath = sourcePath,
+ originalSource = originalSource,
+ altText = element.attr("alt").ifBlank { element.attr("title") }.ifBlank { null },
+ chapterIndex = chapterIndex,
+ chapterTitle = chapter.title.ifBlank { "Chapter ${chapterIndex + 1}" },
+ elementId = element.id().ifBlank { null },
+ ordinalInChapter = ordinal,
+ chunkIndex = element.readerTopLevelBodyChildIndex()?.let { it / 20 },
+ intrinsicWidth = element.readerImageDimension("width"),
+ intrinsicHeight = element.readerImageDimension("height")
+ )
+ }
+ }
+ return references
+}
+
+fun EpubReaderImageReference.readDownloadBytes(): ByteArray? {
+ if (sourcePath.startsWith("data:", ignoreCase = true)) {
+ return sourcePath.readerDataUriBytes()
+ }
+ return runCatching {
+ File(sourcePath).takeIf { it.isFile }?.readBytes()
+ }.getOrNull()
+}
+
+private fun EpubChapter.readerImageHtml(extractionBasePath: String): String {
+ if (htmlContent.isNotBlank()) return htmlContent
+ return runCatching {
+ File(extractionBasePath, htmlFilePath).takeIf { it.isFile }?.readText().orEmpty()
+ }.getOrDefault("")
+}
+
+private fun Element.readerImageSource(): String? {
+ return listOf("src", "href", "xlink:href", "data-src")
+ .firstNotNullOfOrNull { attrName ->
+ attr(attrName).trim().takeIf { it.isNotBlank() }
+ }
+}
+
+private fun Element.readerImageDimension(attribute: String): Int? {
+ val raw = attr(attribute).trim().takeIf { it.isNotBlank() } ?: return null
+ return Regex("""\d+""").find(raw)?.value?.toIntOrNull()?.takeIf { it > 0 }
+}
+
+private fun Element.readerTopLevelBodyChildIndex(): Int? {
+ val body = ownerDocument()?.body() ?: return null
+ var topLevel: Element = this
+ while (topLevel.parent() != null && topLevel.parent() != body) {
+ topLevel = topLevel.parent() ?: break
+ }
+ if (topLevel.parent() != body) return null
+ return body.childNodes().indexOf(topLevel).takeIf { it >= 0 }
+}
+
+private fun resolveReaderImageSource(
+ chapter: EpubChapter,
+ extractionBasePath: String,
+ source: String
+): String {
+ val withoutFragment = source.substringBefore('#').substringBefore('?')
+ if (withoutFragment.startsWith("data:", ignoreCase = true)) return source
+
+ val fileUriPath = withoutFragment.readerFileUriPath()
+ fileUriPath?.let { path ->
+ val file = File(path)
+ if (file.isFile) {
+ return runCatching { file.canonicalFile.absolutePath }.getOrDefault(file.absolutePath)
+ }
+ }
+
+ val sourceForResolve = fileUriPath ?: withoutFragment
+ AndroidHtmlResourceResolver.resolvePath(chapter.absPath, extractionBasePath, sourceForResolve)?.let {
+ return it
+ }
+
+ val fallbackCandidates = listOf(
+ File(extractionBasePath, sourceForResolve),
+ File(extractionBasePath, sourceForResolve.trimStart('/', '\\'))
+ )
+ return fallbackCandidates
+ .firstOrNull { it.isFile }
+ ?.let { runCatching { it.canonicalFile.absolutePath }.getOrDefault(it.absolutePath) }
+ ?: source
+}
+
+private fun String.readerFileUriPath(): String? {
+ if (!startsWith("file:", ignoreCase = true)) return null
+ return runCatching {
+ URI(this).path?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }
+ }.getOrNull()
+}
+
+private fun String.readerImageLookupKey(): String {
+ return substringBefore('#')
+ .substringBefore('?')
+ .replace('\\', '/')
+ .lowercase()
+}
+
+private fun String.readerImageExtension(): String? {
+ readerDataUriMimeType(this)?.let { mime ->
+ return when (mime.lowercase()) {
+ "image/jpeg" -> "jpg"
+ "image/png" -> "png"
+ "image/gif" -> "gif"
+ "image/webp" -> "webp"
+ "image/bmp" -> "bmp"
+ "image/svg+xml" -> "svg"
+ else -> null
+ }
+ }
+ return substringBefore('#')
+ .substringBefore('?')
+ .substringAfterLast('.', "")
+ .lowercase()
+ .takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
+}
+
+private fun readerDataUriMimeType(source: String): String? {
+ if (!source.startsWith("data:", ignoreCase = true)) return null
+ return source
+ .drop(5)
+ .substringBefore(';')
+ .substringBefore(',')
+ .takeIf { it.startsWith("image/", ignoreCase = true) }
+}
+
+private fun String.readerDataUriBytes(): ByteArray? {
+ val commaIndex = indexOf(',')
+ if (!startsWith("data:", ignoreCase = true) || commaIndex == -1) return null
+ val metadata = substring(0, commaIndex)
+ val data = substring(commaIndex + 1)
+ return runCatching {
+ if (metadata.contains(";base64", ignoreCase = true)) {
+ Base64.getDecoder().decode(data)
+ } else {
+ URLDecoder.decode(data, StandardCharsets.UTF_8.name()).toByteArray(StandardCharsets.UTF_8)
+ }
+ }.getOrNull()
+}
+
+private fun String.sanitizedReaderImageFileBase(): String {
+ return replace(Regex("""[\\/:*?"<>|]+"""), "_")
+ .replace(Regex("""\s+"""), " ")
+ .trim()
+ .trim('.')
+ .take(80)
+}
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 61cc316..3f8996e 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -158,6 +158,9 @@ import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
+import com.aryan.reader.ReaderBrightnessEffect
+import com.aryan.reader.ReaderFileInfoDialogs
+import com.aryan.reader.ReaderBrightnessSheet
import com.aryan.reader.ReaderScreenOrientationEffect
import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel
@@ -176,13 +179,17 @@ import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
+import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadEpubRightToLeftPagination
import com.aryan.reader.loadReaderThemeId
+import com.aryan.reader.loadReaderSliderToggled
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
+import com.aryan.reader.readerSliderBookmarkPosition
+import com.aryan.reader.readerSliderChromeColors
+import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.BookPaginator
-import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock
@@ -198,10 +205,13 @@ import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
+import com.aryan.reader.saveReaderBrightnessSettings
import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.saveEpubRightToLeftPagination
import com.aryan.reader.saveReaderThemeId
+import com.aryan.reader.saveReaderSliderToggled
import com.aryan.reader.saveTtsReplacementPreferences
+import com.aryan.reader.shouldRenderReaderSlider
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.tts.SpeakerSamplePlayer
@@ -210,6 +220,7 @@ import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import com.aryan.reader.shared.reader.ReaderJumpHistory
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -217,6 +228,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
@@ -246,7 +258,7 @@ private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
-private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1
+private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
@@ -264,6 +276,25 @@ private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String
.take(maxLength)
}
+private fun List.withInitialChunkOverride(
+ startChunkIndex: Int,
+ initialChunk: TtsChunk?
+): List {
+ if (initialChunk == null || startChunkIndex !in indices) return this
+ val existing = this[startChunkIndex]
+ if (
+ existing.text == initialChunk.text &&
+ existing.sourceCfi == initialChunk.sourceCfi &&
+ existing.startOffsetInSource == initialChunk.startOffsetInSource
+ ) {
+ return this
+ }
+
+ return toMutableList().also { chunks ->
+ chunks[startChunkIndex] = initialChunk
+ }
+}
+
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
@@ -313,7 +344,7 @@ private fun loadHiddenTools(context: Context): Set {
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
- val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
+ val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion)
prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
@@ -323,6 +354,13 @@ private fun loadHiddenTools(context: Context): Set {
return savedHiddenTools
}
+private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set {
+ return buildSet {
+ if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name)
+ if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name)
+ }
+}
+
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 }) }
@@ -644,9 +682,19 @@ fun EpubReaderHost(
) {
val view = LocalView.current
val context = LocalContext.current
+ val uiState by viewModel.uiState.collectAsState()
val window = (view.context as? Activity)?.window
val activity = context as? Activity
val scope = rememberCoroutineScope()
+ var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) }
+ var showBrightnessSheet by remember { mutableStateOf(false) }
+ ReaderBrightnessEffect(window, readerBrightnessSettings)
+
+ val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
+ readerBrightnessSettings = settings
+ saveReaderBrightnessSettings(context, settings)
+ }
+
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
viewModel.showBanner(message, isError, isPersistent)
}
@@ -663,8 +711,8 @@ fun EpubReaderHost(
var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
+ var showFileInfoDialog by remember { mutableStateOf(false) }
- var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf(null) }
@@ -717,6 +765,11 @@ fun EpubReaderHost(
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
+ val bookId = readerCacheBookId
+
+ var isPageSliderVisible by remember(bookId) {
+ mutableStateOf(loadReaderSliderToggled(context, bookId))
+ }
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
@@ -749,7 +802,6 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
- val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@@ -1017,6 +1069,13 @@ fun EpubReaderHost(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showBars by remember { mutableStateOf(false) }
val chapters = remember(epubBook.chapters) { epubBook.chapters }
+ var readerImages by remember(epubBook) { mutableStateOf>(emptyList()) }
+
+ LaunchedEffect(epubBook) {
+ readerImages = withContext(Dispatchers.IO) {
+ epubBook.readerImageReferencesForDrawer()
+ }
+ }
var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf(
@@ -1048,11 +1107,14 @@ fun EpubReaderHost(
var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) }
var chapterChunks by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
+ var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
+ var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
var fragmentToLoad by remember { mutableStateOf(null) }
+ var imageToLoad by remember { mutableStateOf(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf