Epub improvements (#160)

* Improved navigation and search result highlighting in the EPUB reader

* refactor: implement JIT chunk restoration for robust navigation

- Fixes search and bookmark navigation failing in same-chapter transitions.
- Introduced JIT (Just-in-Time) HTML restoration in `epub_reader.js` to
  re-populate virtualized chunks before CFI resolution or search scrolling.
- Synchronized search highlighting and user highlights within
  newly-restored chunk segments.
- Improved search navigation accuracy by mapping occurrences to
  relative chunk indices.

* Implemented a custom text selection engine for the paginated EPUB reader to support cross-page selection and improved handle interaction.

* Implemented management of external file behavior and improved library filtering.

* Added support for toolbar customization in the EPUB reader.

* Added support for toolbar customization in the PDF reader.

* fix: rendering during auto-scroll and navigation on long pages

* Added a "Scroll to Top" feature to the auto-scroll controls in both EPUB and PDF readers.
This commit is contained in:
Aryan 2026-04-10 17:30:48 +05:30 committed by GitHub
parent db7e05ce63
commit 6a60aec0ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3045 additions and 2060 deletions

View file

@ -774,15 +774,16 @@
document.addEventListener("DOMContentLoaded", initializeReaderContent);
}
window.CURRENT_SEARCH_QUERY = "";
window.clearSearchHighlights = function () {
window.CURRENT_SEARCH_QUERY = "";
document.querySelectorAll("mark.search-highlight").forEach(function (el) {
var parent = el.parentNode;
if (parent) {
while (el.firstChild) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
parent.normalize();
}
@ -792,10 +793,12 @@
window.highlightAllOccurrences = function (query) {
window.clearSearchHighlights();
window.CURRENT_SEARCH_QUERY = query;
if (!query || query.length < 2) return "JS: Query too short for highlighting.";
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
var nodesToModify = [];
var nodesToModify =[];
while ((node = walker.nextNode())) {
if (node.nodeValue.toLowerCase().includes(query.toLowerCase())) {
@ -811,11 +814,9 @@
tempDiv.innerHTML = textNode.nodeValue.replace(regex, '<mark class="search-highlight">$1</mark>');
var parent = textNode.parentNode;
while (tempDiv.firstChild) {
parent.insertBefore(tempDiv.firstChild, textNode);
}
parent.removeChild(textNode);
}
});
@ -823,17 +824,37 @@
return "JS: Highlighted " + document.querySelectorAll("mark.search-highlight").length + " occurrences.";
};
window.scrollToOccurrence = function (index) {
var highlights = document.querySelectorAll("mark.search-highlight");
window.scrollToChunkOccurrence = function (chunkIndex, relativeIndex) {
console.log("NavDiag: scrollToChunkOccurrence chunk=" + chunkIndex + ", relativeIdx=" + relativeIndex);
var chunkDiv = document.querySelector(`.chunk-container[data-chunk-index='${chunkIndex}']`);
if (highlights && index >= 0 && index < highlights.length) {
var element = highlights[index];
if (chunkDiv) {
let wasEmpty = false;
element.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
return "JS: Scrolled to occurrence " + index;
if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
console.log("NavDiag: Chunk was empty, restoring content before scrolling.");
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkDiv.style.height = "";
wasEmpty = true;
}
var highlights = chunkDiv.querySelectorAll("mark.search-highlight");
if ((wasEmpty || highlights.length === 0) && window.CURRENT_SEARCH_QUERY) {
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
highlights = chunkDiv.querySelectorAll("mark.search-highlight");
}
if (highlights && highlights.length > 0) {
var targetIdx = (relativeIndex >= 0 && relativeIndex < highlights.length) ? relativeIndex : 0;
highlights[targetIdx].scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
return "JS: Scrolled to relative occurrence " + targetIdx + " in chunk " + chunkIndex;
} else {
chunkDiv.scrollIntoView({ behavior: "auto", block: "center" });
return "JS: No highlights in chunk, scrolled to chunk center.";
}
}
return "JS: Occurrence " + index + " not found.";
return "JS: Chunk " + chunkIndex + " not found.";
};
window.removeHighlight = function () {
@ -1429,6 +1450,15 @@
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
if (chunkElement) {
if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution.");
chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkElement.style.height = "";
if (window.CURRENT_HIGHLIGHTS) {
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
}
}
let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE);
if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) {
currentNode = elementsInChunk[indexInChunk];
@ -1649,7 +1679,7 @@
cleanCfi = cfi.substring(cfi.indexOf('@') + 1);
}
console.log("PosSaveDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
if (!cleanCfi || !cleanCfi.startsWith('/')) {
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
@ -1719,6 +1749,7 @@
}
if (Math.abs(window.scrollY - targetScrollY) > 1) {
console.log("NavDiag: Scrolling to targetY=" + targetScrollY);
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
}
@ -1947,6 +1978,9 @@
if (window.CURRENT_HIGHLIGHTS) {
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
}
if (window.CURRENT_SEARCH_QUERY) {
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
}
}
} else {
if (div.innerHTML !== "") {
@ -2005,6 +2039,10 @@
if (window.CURRENT_HIGHLIGHTS) {
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
}
if (window.CURRENT_SEARCH_QUERY) {
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
}
}
if (window.checkImagesForDiagnosis) {

View file

@ -70,6 +70,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
@ -83,6 +84,7 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -163,6 +165,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
@ -308,7 +311,8 @@ fun HomeScreen(
onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
onTabsToggle = viewModel::setTabsEnabled
onTabsToggle = viewModel::setTabsEnabled,
onExternalFileBehaviorClick = { showBehaviorDialog = true }
)
} else {
ContextualTopAppBar(
@ -443,6 +447,20 @@ fun HomeScreen(
onDismiss = { showClearReflowCacheDialog = false }
)
}
if (uiState.showExternalFileSavePromptFor != null) {
ExternalFileSaveDialog(
onConfirm = { keep, dontAskAgain ->
viewModel.handleExternalFilePrompt(uiState.showExternalFileSavePromptFor!!, keep, dontAskAgain)
}
)
}
if (showBehaviorDialog) {
ExternalFileBehaviorDialog(
currentBehavior = uiState.externalFileBehavior,
onDismiss = { showBehaviorDialog = false },
onSelect = { viewModel.setExternalFileBehavior(it) }
)
}
}
}
if (showAboutDialog) {
@ -758,7 +776,8 @@ fun DefaultTopAppBar(
onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit
onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
@ -822,6 +841,11 @@ fun DefaultTopAppBar(
}
})
DropdownMenuItem(text = { Text(stringResource(R.string.options_external_file_behavior)) }, onClick = {
onExternalFileBehaviorClick()
showOptionsMenu = false
})
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache()
@ -1285,3 +1309,77 @@ fun DangerousFolderActionDialog(
}
)
}
@Composable
fun ExternalFileSaveDialog(
onConfirm: (keep: Boolean, dontAskAgain: Boolean) -> Unit
) {
var dontAsk by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
title = { Text(stringResource(R.string.external_file_prompt_title)) },
text = {
Column {
Text(stringResource(R.string.external_file_prompt_desc))
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { dontAsk = !dontAsk }
) {
Checkbox(checked = dontAsk, onCheckedChange = { dontAsk = it })
Text(stringResource(R.string.external_file_dont_ask))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(true, dontAsk) }) {
Text(stringResource(R.string.external_file_keep))
}
},
dismissButton = {
TextButton(
onClick = { onConfirm(false, dontAsk) },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.external_file_delete))
}
}
)
}
@Composable
fun ExternalFileBehaviorDialog(
currentBehavior: String,
onDismiss: () -> Unit,
onSelect: (String) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
Column {
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
options.forEach { (value, labelRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(value); onDismiss() }
.padding(vertical = 12.dp)
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(labelRes))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
}
)
}

View file

@ -1878,22 +1878,28 @@ fun LibraryFilterSheet(
}
}
if (syncedFolders.isNotEmpty()) {
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
syncedFolders.forEach { folder ->
FilterChip(
selected = folder.uriString in currentFilters.sourceFolders,
onClick = {
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(folder.name) }
)
}
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = "IN_APP_STORAGE" in currentFilters.sourceFolders,
onClick = {
val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE"
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(stringResource(R.string.filter_in_app_storage)) }
)
syncedFolders.forEach { folder ->
FilterChip(
selected = folder.uriString in currentFilters.sourceFolders,
onClick = {
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
currentFilters = currentFilters.copy(sourceFolders = newSet)
},
label = { Text(folder.name) }
)
}
}

View file

@ -105,7 +105,7 @@ class MainActivity : ComponentActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
viewModel.onFileSelected(uri)
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
}
}
}

View file

@ -240,6 +240,8 @@ data class ReaderScreenState(
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -279,6 +281,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = null
data class PageModificationResult(
val layout: List<VirtualPage>,
@ -348,6 +351,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch(_: Exception) { emptyList() }
} ?: emptyList(),
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
)
)
@ -370,7 +374,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val filters = internalState.libraryFilters
val libraryFiltered = baseVisibleFiles.filter { item ->
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
val matchFolder = if (filters.sourceFolders.isNotEmpty()) item.sourceFolderUri in filters.sourceFolders else true
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && item.sourceFolderUri == null && item.uriString?.startsWith("opds-pse") != true
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
matchesInApp || matchesSynced
} else true
val progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
@ -963,7 +971,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
withContext(Dispatchers.Main) {
onDeleted()
showBanner(appContext.getString(R.string.banner_text_view_deleted))
}
}
}
@ -1487,6 +1494,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString()
@ -1504,6 +1512,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
if (closingBookId != null && closingBookId == externalOpenedBookId) {
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") {
deleteBookPermanently(closingBookId)
}
externalOpenedBookId = null
}
if (uriString != null) {
viewModelScope.launch {
val freshBook = recentFilesRepository.getFileByUri(uriString)
@ -2819,7 +2837,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false) {
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
viewModelScope.launch {
@ -2832,11 +2850,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} else {
Timber.i("Importing new file: $uri")
importExternalFile(uri)
importExternalFile(uri, isExternalIntent)
}
}
private fun importExternalFile(externalUri: Uri) {
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
_internalState.update {
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
}
@ -2846,6 +2864,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) {
val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
externalOpenedBookId = bookId
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook(
internalUri, bookId = bookId, type = type, originalDisplayName = displayName
@ -3734,12 +3755,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun selectAllRecentFiles() {
val recentFilesForHome = uiState.value.recentFiles.filter { it.isRecent }
_internalState.update { it.copy(contextualActionItems = recentFilesForHome.toSet()) }
val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet()
_internalState.update { state ->
if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) {
state.copy(contextualActionItems = emptySet())
} else {
state.copy(contextualActionItems = currentVisible)
}
}
}
fun selectAllLibraryFiles() {
_internalState.update { it.copy(contextualActionItems = uiState.value.recentFiles.toSet()) }
val currentVisible = uiState.value.allRecentFiles.toSet()
_internalState.update { state ->
if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) {
state.copy(contextualActionItems = emptySet())
} else {
state.copy(contextualActionItems = currentVisible)
}
}
}
fun clearContextualAction() {
@ -3753,6 +3787,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showCreateShelfDialog = true) }
}
fun handleExternalFilePrompt(bookId: String, keep: Boolean, dontAskAgain: Boolean) {
if (dontAskAgain) {
val newBehavior = if (keep) "KEEP" else "DELETE"
setExternalFileBehavior(newBehavior)
}
if (!keep) {
deleteBookPermanently(bookId)
}
_internalState.update { it.copy(showExternalFileSavePromptFor = null) }
}
fun setExternalFileBehavior(behavior: String) {
prefs.edit { putString(KEY_EXTERNAL_FILE_BEHAVIOR, behavior) }
_internalState.update { it.copy(externalFileBehavior = behavior) }
}
fun dismissCreateShelfDialog() {
_internalState.update { it.copy(showCreateShelfDialog = false) }
}
@ -4361,5 +4410,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_TABS_ENABLED = "tabs_enabled"
private const val KEY_OPEN_TAB_IDS = "open_tab_ids"
private const val KEY_ACTIVE_TAB = "active_tab_book_id"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
}
}

View file

@ -577,6 +577,11 @@ fun ChapterWebView(
)
}
message.startsWith("NavDiag:") -> {
Timber.tag("NavDiag")
.d("JS -> ${message.substringAfter("NavDiag: ")}")
}
message.startsWith("AutoScrollDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}"
@ -726,13 +731,13 @@ fun ChapterWebView(
if (!initialCfi.isNullOrBlank()) {
val cfiJsCommand = "javascript:window.scrollToCfi('$initialCfi');"
Timber.tag("POS_DIAG").d("WebView onPageFinished: Triggering scroll to initialCfi: $initialCfi")
Timber.tag("NavDiag").d("WebView onPageFinished: Triggering scroll to initialCfi: $initialCfi")
view?.evaluateJavascript(cfiJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (!initialFragmentId.isNullOrBlank()) {
Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null
@ -744,9 +749,7 @@ fun ChapterWebView(
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
else -> "javascript:window.scrollToChapterStart();"
}
Timber.d(
"WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget"
)
Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget")
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
@ -754,17 +757,13 @@ fun ChapterWebView(
} else if (initialPageScrollY != null && initialPageScrollY > 0) {
val scrollJsCommand =
"javascript:window.scrollToSpecificY($initialPageScrollY);"
Timber.d(
"WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY"
)
Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY")
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else {
Timber.d(
"WebView onPageFinished: No specific scroll, defaulting to start."
)
Timber.tag("NavDiag").d("WebView onPageFinished: No specific scroll, defaulting to start.")
view?.evaluateJavascript("javascript:window.scrollToChapterStart();") {
onChapterInitiallyScrolled()
scrollActionTaken = true

View file

@ -30,6 +30,11 @@ import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Switch
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -69,6 +74,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
@ -131,6 +137,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
enum class ReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
FORMAT("Text Formatting", "Bottom Bar"),
SEARCH("Search", "Bottom Bar"),
AI_FEATURES("AI Features", "Bottom Bar"),
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
READING_MODE("Reading Mode", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
}
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
@ -158,6 +184,8 @@ fun EpubReaderTopBar(
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
hiddenTools: Set<String>,
onCustomizeTools: () -> Unit,
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
@ -204,22 +232,26 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onOpenDictionarySettings
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
)
if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onOpenDictionarySettings
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
)
}
}
TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
if (!hiddenTools.contains(ReaderTool.THEME.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
@ -235,6 +267,18 @@ fun EpubReaderTopBar(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text("Customize Toolbar") },
onClick = {
showMoreMenu = false
onCustomizeTools()
},
leadingIcon = {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
HorizontalDivider()
if (onToggleReflow != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
@ -274,125 +318,175 @@ fun EpubReaderTopBar(
)
}
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 = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
onClick = {
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
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 = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED) 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()
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()
DropdownMenuItem(
text = {
})
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)
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()
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()
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()
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()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
showMoreMenu = false
onStartAutoScroll()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
},
leadingIcon = {
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
if (BuildConfig.DEBUG) {
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_tts_settings_debug)) },
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
onOpenTtsSettings()
onOpenVisualOptions()
},
leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
}
)
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()
}
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
})
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.text_to_speech),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
}
}
}
@ -418,6 +512,7 @@ fun EpubReaderBottomBar(
onRecap: () -> Unit,
onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit,
hiddenTools: Set<String>,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@ -439,107 +534,131 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(
painter = painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider)
)
}
}
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer
) {
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
if (!hiddenTools.contains(ReaderTool.TOC.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer
) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.content_desc_chapters_menu)
)
}
}
TooltipIconButton(
text = stringResource(R.string.tooltip_format),
description = stringResource(R.string.tooltip_format_desc),
onClick = onToggleFormat
) {
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting))
if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_format),
description = stringResource(R.string.tooltip_format_desc),
onClick = onToggleFormat
) {
Icon(
painter = painterResource(id = R.drawable.format_size),
contentDescription = stringResource(R.string.content_desc_text_formatting)
)
}
}
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch
) {
Icon(imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.tooltip_search)
)
}
}
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }
) {
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = {
showAiFeaturesMenu = false
onSummarize()
}
)
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
@Suppress(
"KotlinConstantConditions",
"SimplifyBooleanWithConstants"
) if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features"
)
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_recap_beta)) },
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = {
showAiFeaturesMenu = false
onRecap()
}
)
onSummarize()
})
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_recap_beta)) },
onClick = {
showAiFeaturesMenu = false
onRecap()
})
}
}
}
}
}
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton(
text = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop)
else
stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop_desc)
else
stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts)
)
}
if (isTtsSessionActive) {
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton(
text = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause)
else
stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) else stringResource(R.string.content_desc_resume_tts)
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
id = R.drawable.text_to_speech
),
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
R.string.content_desc_start_tts
)
)
}
if (isTtsSessionActive) {
TooltipIconButton(
text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
else stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
else stringResource(R.string.tooltip_tts_resume_desc),
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) stringResource(
R.string.content_desc_pause_tts
) else stringResource(R.string.content_desc_resume_tts)
)
}
}
}
}
}
@ -885,6 +1004,7 @@ fun AutoScrollControls(
onLocalModeToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
isTempPaused: Boolean = false,
onScrollToTop: (() -> Unit)? = null
) {
val backgroundAlpha = 0.6f
@ -1018,6 +1138,19 @@ fun AutoScrollControls(
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (onScrollToTop != null) {
IconButton(
onClick = onScrollToTop,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.ArrowUpward,
contentDescription = "Scroll to Top",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = onMusicianModeToggle,
modifier = Modifier.size(32.dp)
@ -1217,3 +1350,75 @@ fun AutoScrollControls(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomizeToolsSheet(
hiddenTools: Set<String>,
onUpdate: (Set<String>) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text(
text = "Customize Toolbar",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Select the tools you want to keep visible. Unchecking a tool hides it from the UI to give you a distraction-free reading space.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) {
ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
item {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
items(tools) { tool ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
val newSet = hiddenTools.toMutableSet()
if (newSet.contains(tool.name)) newSet.remove(tool.name)
else newSet.add(tool.name)
onUpdate(newSet)
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = !hiddenTools.contains(tool.name),
onCheckedChange = { isVisible ->
val newSet = hiddenTools.toMutableSet()
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
onUpdate(newSet)
}
)
}
}
}
}
}
}
}

View file

@ -214,6 +214,17 @@ private const val AUTO_SCROLL_LOCAL_MIN_PREFIX = "auto_scroll_local_min_"
private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
}
private fun loadHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
}
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -655,6 +666,9 @@ fun EpubReaderHost(
mutableStateOf(loadExternalSearchPackage(context))
}
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -1603,6 +1617,7 @@ fun EpubReaderHost(
}
fun navigateToSearchResult(index: Int) {
Timber.tag("NavDiag").d("navigateToSearchResult index: $index")
performSearchResultNavigation(
index = index,
searchState = searchState,
@ -1613,17 +1628,36 @@ fun EpubReaderHost(
paginator = paginator,
coroutineScope = scope,
onVerticalChapterChange = { chapterIdx, chunkIdx, result ->
initialScrollTargetForChapter = ChapterScrollPosition.START
Timber.tag("NavDiag").d("onVerticalChapterChange chapterIdx=$chapterIdx, chunkIdx=$chunkIdx, query=${result.query}")
initialScrollTargetForChapter = null
chunkTargetOverride = chunkIdx
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = chapterIdx
searchHighlightTarget = result
loadUpToChunkIndex = chunkIdx
},
onVerticalScrollToResult = { _ ->
searchHighlightTarget = null
onVerticalScrollToResult = { result ->
Timber.tag("NavDiag").d("onVerticalScrollToResult query=${result.query}, chunk=${result.chunkIndex}")
val targetChunk = result.chunkIndex
if (targetChunk >= loadedChunkCount) {
val chunksToInject = (loadedChunkCount..targetChunk)
chunksToInject.forEach { idx ->
val content = chapterChunks.getOrNull(idx)
if (content != null) {
val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($idx, '$escaped');",
null
)
}
}
loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
}
searchHighlightTarget = result
},
onPaginatedScrollToPage = { pageIdx ->
Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx")
paginatedPagerState.scrollToPage(pageIdx)
}
)
@ -1763,23 +1797,8 @@ fun EpubReaderHost(
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi
val directChunkIndex = try {
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() }
if (parts.isNotEmpty()) {
val firstIndex = parts[0]
(firstIndex - 2) / 2
} else null
} catch (_: Exception) {
null
}
val locator = if (directChunkIndex == null) {
locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
} else {
null
}
val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 }
val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
val targetChunk = locator?.let { it.blockIndex / 20 }
if (bookmark.chapterIndex != currentChapterIndex) {
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
@ -2168,7 +2187,7 @@ fun EpubReaderHost(
} else if (chapterChunks.isNotEmpty()) {
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val targetIdx = loadUpToChunkIndex
val startIdx = maxOf(0, targetIdx - 1)
val startIdx = 0
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
chapterChunks.indices.joinToString(separator = "\n") { index ->
@ -2221,30 +2240,31 @@ fun EpubReaderHost(
)
}
LaunchedEffect(isWebViewReady) {
LaunchedEffect(isWebViewReady, searchHighlightTarget) {
val target = searchHighlightTarget
Timber.d("Effect(isWebViewReady=$isWebViewReady) triggered for chapter $targetChapterIndex. Target is: $target"
)
Timber.tag("NavDiag").d("Effect(isWebViewReady=$isWebViewReady, target=$target) triggered for chapter $targetChapterIndex.")
if (isWebViewReady && target != null && target.locationInSource == targetChapterIndex) {
Timber.d("Highlighting condition met. Highlighting now."
)
Timber.tag("NavDiag").d("Highlighting condition met. Highlighting now.")
delay(200)
val webView = webViewRefForTts
if (webView != null) {
val escapedQuery = escapeJsString(target.query)
val js =
"javascript:window.highlightAllOccurrences('${escapedQuery}'); window.scrollToOccurrence(${target.occurrenceIndexInLocation});"
Timber.d("Executing search highlight/scroll JS: $js"
)
val targetChunk = target.chunkIndex
val relativeIdx = searchState.searchResults
.filter { it.locationInSource == target.locationInSource && it.chunkIndex == targetChunk }
.indexOf(target)
.coerceAtLeast(0)
val js = "javascript:console.log('NavDiag: Executing robust search highlight JS'); window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);"
Timber.tag("NavDiag").d("Executing search highlight/scroll JS: $js")
webView.evaluateJavascript(js) { result ->
Timber.d("JS highlight/scroll result: $result"
)
Timber.tag("NavDiag").d("JS highlight/scroll result: $result")
}
searchHighlightTarget = null
} else {
Timber.w("Highlight failed: WebView was null even after ready signal."
)
Timber.tag("NavDiag").w("Highlight failed: WebView was null even after ready signal.")
searchHighlightTarget = null
}
}
@ -2305,6 +2325,7 @@ fun EpubReaderHost(
},
onChapterInitiallyScrolled = {
val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
initialScrollTargetForChapter = null
cfiToLoad = null
fragmentToLoad = null
@ -3485,6 +3506,8 @@ fun EpubReaderHost(
tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
hiddenTools = hiddenTools,
onCustomizeTools = { showCustomizeToolsSheet = true },
onNavigateBack = { triggerSaveAndExit() },
isKeepScreenOn = isKeepScreenOn,
onToggleKeepScreenOn = { enabled ->
@ -3497,23 +3520,34 @@ fun EpubReaderHost(
keyboardController?.hide()
focusManager.clearFocus()
containerFocusRequester.requestFocus()
webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
},
onChangeRenderMode = { newMode ->
Timber.tag("NavDiag").d("onChangeRenderMode to $newMode")
if (newMode != currentRenderMode) {
if (newMode == RenderMode.PAGINATED) {
isSwitchingToPaginated = true
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
} else {
scope.launch {
Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator")
lastKnownLocator?.let { locator ->
val cfi = locatorConverter.getCfiFromLocator(epubBook, locator)
Timber.tag("NavDiag").d("Converted locator to CFI: $cfi")
if (cfi != null) {
val targetChunk = locator.blockIndex / 20
chunkTargetOverride = targetChunk
if (currentChapterIndex != locator.chapterIndex) {
initialScrollTargetForChapter = null
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = locator.chapterIndex
} else {
if (targetChunk > loadUpToChunkIndex) {
loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
}
initialScrollTargetForChapter = null
}
cfiToLoad = cfi
} else {
@ -3661,7 +3695,15 @@ fun EpubReaderHost(
saveAutoScrollUseSlider(context, autoScrollUseSlider)
},
isLocalMode = isAutoScrollLocal,
onLocalModeToggle = onToggleAutoScrollMode
onLocalModeToggle = onToggleAutoScrollMode,
onScrollToTop = {
if (isAutoScrollPlaying) {
triggerAutoScrollTempPause(1000L)
}
scope.launch {
webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null)
}
}
)
}
@ -3672,6 +3714,7 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive,
ttsState = ttsState,
isProUser = isProUser,
hiddenTools = hiddenTools,
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@ -4123,6 +4166,17 @@ fun EpubReaderHost(
)
}
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,
onUpdate = { newHiddenSet ->
hiddenTools = newHiddenSet
saveHiddenTools(context, newHiddenSet)
},
onDismiss = { showCustomizeToolsSheet = false }
)
}
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,

View file

@ -294,7 +294,7 @@ class OpdsParser {
private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry {
val title = nav.optString("title", "Unknown")
val href = nav.optString("href")
val summary = nav.optString("description", null)
val summary = nav.optString("description")
val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null
return OpdsEntry(

View file

@ -21,7 +21,15 @@ class OpdsRepository(context: Context) {
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
val sharedHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
OkHttpClient.Builder()
.addInterceptor { chain ->
val originalRequest = chain.request()
val requestWithUserAgent = originalRequest.newBuilder()
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
chain.proceed(requestWithUserAgent)
}
.build()
}
}
@ -54,6 +62,8 @@ class OpdsRepository(context: Context) {
if (catalogs.isEmpty()) {
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true))
saveCatalogs(catalogs)
}

View file

@ -123,7 +123,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -1104,12 +1104,12 @@ internal fun PdfPageComposable(
try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
snapshotFlow { visibleScreenRect() }.collectLatest { currentVisibleRect ->
snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect ->
val tileCalcStart = System.nanoTime()
if (!isActive) return@collectLatest
if (!isActive) return@collect
if (isScrolling && effectiveScale > 1f) {
return@collectLatest
return@collect
}
val pxTl: Float
@ -1131,7 +1131,7 @@ internal fun PdfPageComposable(
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
return@collectLatest
return@collect
}
} else {
val pivotX = screenWidth / 2f

View file

@ -139,6 +139,8 @@ class VerticalPdfReaderState {
internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null
internal var snapToPageHandler: (suspend (Int) -> Unit)? = null
internal var scrollByHandler: (suspend (Float) -> Unit)? = null
internal var scrollToTopHandler: (suspend () -> Unit)? = null
internal var scrollToBottomHandler: (suspend () -> Unit)? = null
suspend fun scrollToPage(pageIndex: Int) {
scrollToPageHandler?.invoke(pageIndex)
@ -151,6 +153,14 @@ class VerticalPdfReaderState {
suspend fun scrollBy(delta: Float) {
scrollByHandler?.invoke(delta)
}
suspend fun scrollToTop() {
scrollToTopHandler?.invoke()
}
suspend fun scrollToBottom() {
scrollToBottomHandler?.invoke()
}
}
@Composable
@ -244,6 +254,8 @@ internal fun PdfVerticalReader(
state.scrollToPageHandler = null
state.snapToPageHandler = null
state.scrollByHandler = null
state.scrollToTopHandler = null
state.scrollToBottomHandler = null
}
}
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
@ -527,6 +539,23 @@ internal fun PdfVerticalReader(
)
}
}
state.scrollToTopHandler = {
panYAnimatable.animateTo(
targetValue = headerHeightPx,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
state.scrollToBottomHandler = {
val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
panYAnimatable.animateTo(
targetValue = minPanY,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
}
var selectionClearTrigger by remember { mutableLongStateOf(0L) }

File diff suppressed because it is too large Load diff

View file

@ -420,7 +420,7 @@ class PdfRichTextRepository(private val context: Context) {
isItalic = sObj.optBoolean("i"),
isUnderline = sObj.optBoolean("u"),
isStrikethrough = sObj.optBoolean("st"),
fontPath = sObj.optString("fp", null),
fontPath = sObj.optString("fp"),
)
)
}

View file

@ -200,8 +200,8 @@ object TextBoxSerializer {
isItalic = obj.optBoolean("isItalic", false),
isUnderline = obj.optBoolean("isUnderline", false),
isStrikeThrough = obj.optBoolean("isStrikeThrough", false),
fontPath = obj.optString("fontPath", null).takeIf { !it.isNullOrBlank() },
fontName = obj.optString("fontName", null).takeIf { !it.isNullOrBlank() }
fontPath = obj.optString("fontPath").takeIf { !it.isNullOrBlank() },
fontName = obj.optString("fontName").takeIf { !it.isNullOrBlank() }
)
)
}
@ -269,7 +269,7 @@ object HighlightSerializer {
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
text = obj.optString("text", ""),
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)),
note = obj.optString("note", null).takeIf { !it.isNullOrBlank() }
note = obj.optString("note").takeIf { !it.isNullOrBlank() }
)
)
}

View file

@ -139,6 +139,18 @@
<string name="filter_source_folder">Source Folder</string>
<string name="filter_read_status">Read Status</string>
<string name="clear_all">Clear All</string>
<string name="filter_in_app_storage">In-App Storage</string>
<!-- External File Behavior -->
<string name="external_file_prompt_title">Save File?</string>
<string name="external_file_prompt_desc">Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen > More Options > External File Behavior).</string>
<string name="external_file_dont_ask">Don\'t ask again</string>
<string name="external_file_keep">Keep in Library</string>
<string name="external_file_delete">Remove</string>
<string name="external_file_behavior_ask">Ask Every Time</string>
<string name="external_file_behavior_keep">Always Keep</string>
<string name="external_file_behavior_delete">Always Remove</string>
<string name="options_external_file_behavior">External File Behavior</string>
<!-- OPDS -->
<string name="fab_add_catalog">Add Catalog</string>