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

View file

@ -70,6 +70,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@ -83,6 +84,7 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -163,6 +165,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) } var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) }
@ -308,7 +311,8 @@ fun HomeScreen(
onFolderSyncToggle = viewModel::setFolderSyncEnabled, onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true }, onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit, onRecentFilesLimitChange = viewModel::setRecentFilesLimit,
onTabsToggle = viewModel::setTabsEnabled onTabsToggle = viewModel::setTabsEnabled,
onExternalFileBehaviorClick = { showBehaviorDialog = true }
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -443,6 +447,20 @@ fun HomeScreen(
onDismiss = { showClearReflowCacheDialog = false } 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) { if (showAboutDialog) {
@ -758,7 +776,8 @@ fun DefaultTopAppBar(
onShowDeviceManagement: () -> Unit, onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit, onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit, onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu 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() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache() onClearCache()
@ -1284,4 +1308,78 @@ 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)
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium) Row(
Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp) ) {
) { FilterChip(
syncedFolders.forEach { folder -> selected = "IN_APP_STORAGE" in currentFilters.sourceFolders,
FilterChip( onClick = {
selected = folder.uriString in currentFilters.sourceFolders, val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE"
onClick = { currentFilters = currentFilters.copy(sourceFolders = newSet)
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString },
currentFilters = currentFilters.copy(sourceFolders = newSet) label = { Text(stringResource(R.string.filter_in_app_storage)) }
}, )
label = { Text(folder.name) } 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) { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}") Timber.d("Received VIEW intent with URI: ${intent.data}")
val 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 openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(), val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null, val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -279,6 +281,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
@Suppress("unused") @Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow() val navigationEvent = _navigationEvent.receiveAsFlow()
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = null
data class PageModificationResult( data class PageModificationResult(
val layout: List<VirtualPage>, val layout: List<VirtualPage>,
@ -348,6 +351,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch(_: Exception) { emptyList() } } catch(_: Exception) { emptyList() }
} ?: emptyList(), } ?: emptyList(),
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), 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 filters = internalState.libraryFilters
val libraryFiltered = baseVisibleFiles.filter { item -> val libraryFiltered = baseVisibleFiles.filter { item ->
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true 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 progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) { val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true ReadStatusFilter.ALL -> true
@ -963,7 +971,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onDeleted() 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() val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.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) { if (uriString != null) {
viewModelScope.launch { viewModelScope.launch {
val freshBook = recentFilesRepository.getFileByUri(uriString) 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) { if (isFromRecent) {
Timber.i("Opening recent file: $uri") Timber.i("Opening recent file: $uri")
viewModelScope.launch { viewModelScope.launch {
@ -2832,11 +2850,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} else { } else {
Timber.i("Importing new file: $uri") 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 { _internalState.update {
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
} }
@ -2846,6 +2864,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) { if (importResult != null) {
val (internalUri, bookId, type) = importResult val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
externalOpenedBookId = bookId
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook( openBook(
internalUri, bookId = bookId, type = type, originalDisplayName = displayName internalUri, bookId = bookId, type = type, originalDisplayName = displayName
@ -3734,12 +3755,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun selectAllRecentFiles() { fun selectAllRecentFiles() {
val recentFilesForHome = uiState.value.recentFiles.filter { it.isRecent } val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet()
_internalState.update { it.copy(contextualActionItems = recentFilesForHome.toSet()) } _internalState.update { state ->
if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) {
state.copy(contextualActionItems = emptySet())
} else {
state.copy(contextualActionItems = currentVisible)
}
}
} }
fun selectAllLibraryFiles() { 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() { fun clearContextualAction() {
@ -3753,6 +3787,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showCreateShelfDialog = true) } _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() { fun dismissCreateShelfDialog() {
_internalState.update { it.copy(showCreateShelfDialog = false) } _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_TABS_ENABLED = "tabs_enabled"
private const val KEY_OPEN_TAB_IDS = "open_tab_ids" private const val KEY_OPEN_TAB_IDS = "open_tab_ids"
private const val KEY_ACTIVE_TAB = "active_tab_book_id" 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") -> { message.startsWith("AutoScrollDiagnosis") -> {
Timber.d( Timber.d(
"JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}" "JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}"
@ -726,13 +731,13 @@ fun ChapterWebView(
if (!initialCfi.isNullOrBlank()) { if (!initialCfi.isNullOrBlank()) {
val cfiJsCommand = "javascript:window.scrollToCfi('$initialCfi');" 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) { view?.evaluateJavascript(cfiJsCommand) {
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true
} }
} else if (!initialFragmentId.isNullOrBlank()) { } 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( view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", "javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null null
@ -744,9 +749,7 @@ fun ChapterWebView(
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
else -> "javascript:window.scrollToChapterStart();" else -> "javascript:window.scrollToChapterStart();"
} }
Timber.d( Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget")
"WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget"
)
view?.evaluateJavascript(scrollJsCommand) { view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true
@ -754,17 +757,13 @@ fun ChapterWebView(
} else if (initialPageScrollY != null && initialPageScrollY > 0) { } else if (initialPageScrollY != null && initialPageScrollY > 0) {
val scrollJsCommand = val scrollJsCommand =
"javascript:window.scrollToSpecificY($initialPageScrollY);" "javascript:window.scrollToSpecificY($initialPageScrollY);"
Timber.d( Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY")
"WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY"
)
view?.evaluateJavascript(scrollJsCommand) { view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true
} }
} else { } else {
Timber.d( Timber.tag("NavDiag").d("WebView onPageFinished: No specific scroll, defaulting to start.")
"WebView onPageFinished: No specific scroll, defaulting to start."
)
view?.evaluateJavascript("javascript:window.scrollToChapterStart();") { view?.evaluateJavascript("javascript:window.scrollToChapterStart();") {
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true

View file

@ -30,6 +30,11 @@ import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize 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.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown 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.Check
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
@ -131,6 +137,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.roundToInt 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 @Composable
fun EpubReaderTopBar( fun EpubReaderTopBar(
isVisible: Boolean, isVisible: Boolean,
@ -158,6 +184,8 @@ fun EpubReaderTopBar(
onOpenThemeSettings: () -> Unit, onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit, onOpenVisualOptions: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
hiddenTools: Set<String>,
onCustomizeTools: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null, onToggleReflow: (() -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null, onDeleteReflow: (() -> Unit)? = null,
@ -204,22 +232,26 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
text = stringResource(R.string.tooltip_dictionary), TooltipIconButton(
description = stringResource(R.string.tooltip_dictionary_desc), text = stringResource(R.string.tooltip_dictionary),
onClick = onOpenDictionarySettings description = stringResource(R.string.tooltip_dictionary_desc),
) { onClick = onOpenDictionarySettings
Icon( ) {
painter = painterResource(id = R.drawable.dictionary), Icon(
contentDescription = stringResource(R.string.content_desc_dictionary_settings) painter = painterResource(id = R.drawable.dictionary),
) contentDescription = stringResource(R.string.content_desc_dictionary_settings)
)
}
} }
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.THEME.name)) {
text = stringResource(R.string.tooltip_theme), TooltipIconButton(
description = stringResource(R.string.tooltip_theme_desc), text = stringResource(R.string.tooltip_theme),
onClick = onOpenThemeSettings description = stringResource(R.string.tooltip_theme_desc),
) { onClick = onOpenThemeSettings
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc)) ) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
}
} }
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
@ -235,6 +267,18 @@ fun EpubReaderTopBar(
expanded = showMoreMenu, expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false } 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) { if (onToggleReflow != null) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_view_original_pdf)) }, text = { Text(stringResource(R.string.menu_view_original_pdf)) },
@ -274,125 +318,175 @@ fun EpubReaderTopBar(
) )
} }
DropdownMenuItem( if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, DropdownMenuItem(
enabled = !isTtsActive, text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
onClick = { enabled = !isTtsActive,
showMoreMenu = false onClick = {
onChangeRenderMode(RenderMode.VERTICAL_SCROLL) showMoreMenu = false
}, onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } },
) trailingIcon = {
DropdownMenuItem( if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, Icons.Default.Check,
enabled = !isTtsActive, contentDescription = stringResource(R.string.content_desc_selected)
onClick = { )
showMoreMenu = false })
onChangeRenderMode(RenderMode.PAGINATED) DropdownMenuItem(
}, text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } enabled = !isTtsActive,
) onClick = {
HorizontalDivider() showMoreMenu = false
DropdownMenuItem( onChangeRenderMode(RenderMode.PAGINATED)
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, },
onClick = { 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 showMoreMenu = false
onToggleBookmark() onToggleBookmark()
} })
) HorizontalDivider()
HorizontalDivider() }
DropdownMenuItem( if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, DropdownMenuItem(
enabled = currentRenderMode == RenderMode.PAGINATED, text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
onClick = { enabled = currentRenderMode == RenderMode.PAGINATED,
onToggleTapToNavigate(!tapToNavigateEnabled) onClick = {
showMoreMenu = false onToggleTapToNavigate(!tapToNavigateEnabled)
}, showMoreMenu = false
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } },
) trailingIcon = {
HorizontalDivider() if (tapToNavigateEnabled) Icon(
DropdownMenuItem( Icons.Default.Check,
text = { contentDescription = stringResource(R.string.content_desc_enabled)
)
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
DropdownMenuItem(
text = {
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) else stringResource(R.string.menu_volume_button_page_turn)
) )
}, },
enabled = true, enabled = true,
onClick = { onClick = {
onToggleVolumeScroll(!volumeScrollEnabled) onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false showMoreMenu = false
}, },
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } trailingIcon = {
) if (volumeScrollEnabled) Icon(
HorizontalDivider() Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
DropdownMenuItem( )
text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, })
enabled = currentRenderMode == RenderMode.PAGINATED, HorizontalDivider()
onClick = { }
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
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) {
DropdownMenuItem( 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 = { onClick = {
showMoreMenu = false showMoreMenu = false
onOpenTtsSettings() onOpenVisualOptions()
}, },
leadingIcon = { 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, onRecap: () -> Unit,
onToggleTts: () -> Unit, onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit, onPlayPauseTts: () -> Unit,
hiddenTools: Set<String>,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( AnimatedVisibility(
@ -439,107 +534,131 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceAround
) { ) {
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
text = stringResource(R.string.tooltip_slider), TooltipIconButton(
description = stringResource(R.string.tooltip_slider_desc), text = stringResource(R.string.tooltip_slider),
onClick = onOpenSlider, description = stringResource(R.string.tooltip_slider_desc),
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL onClick = onOpenSlider,
) { enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
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)
)
}
} }
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.TOC.name)) {
text = stringResource(R.string.tooltip_toc), TooltipIconButton(
description = stringResource(R.string.tooltip_toc_desc), text = stringResource(R.string.tooltip_toc),
onClick = onOpenDrawer description = stringResource(R.string.tooltip_toc_desc),
) { onClick = onOpenDrawer
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu)) ) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.content_desc_chapters_menu)
)
}
} }
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
text = stringResource(R.string.tooltip_format), TooltipIconButton(
description = stringResource(R.string.tooltip_format_desc), text = stringResource(R.string.tooltip_format),
onClick = onToggleFormat 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)) ) {
Icon(
painter = painterResource(id = R.drawable.format_size),
contentDescription = stringResource(R.string.content_desc_text_formatting)
)
}
} }
TooltipIconButton( if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
text = stringResource(R.string.tooltip_search), TooltipIconButton(
description = stringResource(R.string.tooltip_search_desc), text = stringResource(R.string.tooltip_search),
onClick = onToggleSearch description = stringResource(R.string.tooltip_search_desc),
) { onClick = onToggleSearch
Icon(imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search)) ) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.tooltip_search)
)
}
} }
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
if (BuildConfig.FLAVOR != "oss") { @Suppress(
Box { "KotlinConstantConditions",
var showAiFeaturesMenu by remember { mutableStateOf(false) } "SimplifyBooleanWithConstants"
TooltipIconButton( ) if (BuildConfig.FLAVOR != "oss") {
text = stringResource(R.string.tooltip_ai), Box {
description = stringResource(R.string.tooltip_ai_desc), var showAiFeaturesMenu by remember { mutableStateOf(false) }
onClick = { showAiFeaturesMenu = true } TooltipIconButton(
) { text = stringResource(R.string.tooltip_ai),
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features") description = stringResource(R.string.tooltip_ai_desc),
} onClick = { showAiFeaturesMenu = true }) {
DropdownMenu( Icon(
expanded = showAiFeaturesMenu, painter = painterResource(id = R.drawable.ai),
onDismissRequest = { showAiFeaturesMenu = false } contentDescription = "AI Features"
) { )
DropdownMenuItem( }
text = { Text(stringResource(R.string.menu_chapter_summarization)) }, DropdownMenu(
onClick = { expanded = showAiFeaturesMenu,
showAiFeaturesMenu = false onDismissRequest = { showAiFeaturesMenu = false }) {
onSummarize()
}
)
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_recap_beta)) }, text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = { onClick = {
showAiFeaturesMenu = false 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) { if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton( Box {
text = if (isTtsSessionActive) Row(verticalAlignment = Alignment.CenterVertically) {
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) {
TooltipIconButton( TooltipIconButton(
text = if (ttsState.isPlaying) text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.tooltip_tts_start),
else description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
stringResource(R.string.tooltip_tts_resume), else stringResource(R.string.tooltip_tts_start_desc),
description = if (ttsState.isPlaying) onClick = onToggleTts
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
) { ) {
Icon( Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) else stringResource(R.string.content_desc_resume_tts) 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, onLocalModeToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTempPaused: Boolean = false, isTempPaused: Boolean = false,
onScrollToTop: (() -> Unit)? = null
) { ) {
val backgroundAlpha = 0.6f val backgroundAlpha = 0.6f
@ -1018,6 +1138,19 @@ fun AutoScrollControls(
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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( IconButton(
onClick = onMusicianModeToggle, onClick = onMusicianModeToggle,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
@ -1216,4 +1349,76 @@ 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 AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_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) { private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -655,6 +666,9 @@ fun EpubReaderHost(
mutableStateOf(loadExternalSearchPackage(context)) mutableStateOf(loadExternalSearchPackage(context))
} }
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -1603,6 +1617,7 @@ fun EpubReaderHost(
} }
fun navigateToSearchResult(index: Int) { fun navigateToSearchResult(index: Int) {
Timber.tag("NavDiag").d("navigateToSearchResult index: $index")
performSearchResultNavigation( performSearchResultNavigation(
index = index, index = index,
searchState = searchState, searchState = searchState,
@ -1613,17 +1628,36 @@ fun EpubReaderHost(
paginator = paginator, paginator = paginator,
coroutineScope = scope, coroutineScope = scope,
onVerticalChapterChange = { chapterIdx, chunkIdx, result -> 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 currentScrollYPosition = 0
currentScrollHeightValue = 0 currentScrollHeightValue = 0
currentChapterIndex = chapterIdx currentChapterIndex = chapterIdx
searchHighlightTarget = result searchHighlightTarget = result
loadUpToChunkIndex = chunkIdx
}, },
onVerticalScrollToResult = { _ -> onVerticalScrollToResult = { result ->
searchHighlightTarget = null 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 -> onPaginatedScrollToPage = { pageIdx ->
Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx")
paginatedPagerState.scrollToPage(pageIdx) paginatedPagerState.scrollToPage(pageIdx)
} }
) )
@ -1763,23 +1797,8 @@ fun EpubReaderHost(
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi cfiToLoad = bookmark.cfi
val directChunkIndex = try { val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() } val targetChunk = locator?.let { it.blockIndex / 20 }
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 }
if (bookmark.chapterIndex != currentChapterIndex) { if (bookmark.chapterIndex != currentChapterIndex) {
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) { chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
@ -2168,7 +2187,7 @@ fun EpubReaderHost(
} else if (chapterChunks.isNotEmpty()) { } else if (chapterChunks.isNotEmpty()) {
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val targetIdx = loadUpToChunkIndex val targetIdx = loadUpToChunkIndex
val startIdx = maxOf(0, targetIdx - 1) val startIdx = 0
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
chapterChunks.indices.joinToString(separator = "\n") { index -> chapterChunks.indices.joinToString(separator = "\n") { index ->
@ -2221,30 +2240,31 @@ fun EpubReaderHost(
) )
} }
LaunchedEffect(isWebViewReady) { LaunchedEffect(isWebViewReady, searchHighlightTarget) {
val target = 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) { 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) delay(200)
val webView = webViewRefForTts val webView = webViewRefForTts
if (webView != null) { if (webView != null) {
val escapedQuery = escapeJsString(target.query) val escapedQuery = escapeJsString(target.query)
val js = val targetChunk = target.chunkIndex
"javascript:window.highlightAllOccurrences('${escapedQuery}'); window.scrollToOccurrence(${target.occurrenceIndexInLocation});"
Timber.d("Executing search highlight/scroll JS: $js" 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 -> webView.evaluateJavascript(js) { result ->
Timber.d("JS highlight/scroll result: $result" Timber.tag("NavDiag").d("JS highlight/scroll result: $result")
)
} }
searchHighlightTarget = null searchHighlightTarget = null
} else { } 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 searchHighlightTarget = null
} }
} }
@ -2305,6 +2325,7 @@ fun EpubReaderHost(
}, },
onChapterInitiallyScrolled = { onChapterInitiallyScrolled = {
val wasCfiScroll = cfiToLoad != null val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
initialScrollTargetForChapter = null initialScrollTargetForChapter = null
cfiToLoad = null cfiToLoad = null
fragmentToLoad = null fragmentToLoad = null
@ -3485,6 +3506,8 @@ fun EpubReaderHost(
tapToNavigateEnabled = tapToNavigateEnabled, tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled, volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
hiddenTools = hiddenTools,
onCustomizeTools = { showCustomizeToolsSheet = true },
onNavigateBack = { triggerSaveAndExit() }, onNavigateBack = { triggerSaveAndExit() },
isKeepScreenOn = isKeepScreenOn, isKeepScreenOn = isKeepScreenOn,
onToggleKeepScreenOn = { enabled -> onToggleKeepScreenOn = { enabled ->
@ -3497,23 +3520,34 @@ fun EpubReaderHost(
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
containerFocusRequester.requestFocus() containerFocusRequester.requestFocus()
webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}, },
onChangeRenderMode = { newMode -> onChangeRenderMode = { newMode ->
Timber.tag("NavDiag").d("onChangeRenderMode to $newMode")
if (newMode != currentRenderMode) { if (newMode != currentRenderMode) {
if (newMode == RenderMode.PAGINATED) { if (newMode == RenderMode.PAGINATED) {
isSwitchingToPaginated = true isSwitchingToPaginated = true
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
} else { } else {
scope.launch { scope.launch {
Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator")
lastKnownLocator?.let { locator -> lastKnownLocator?.let { locator ->
val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) val cfi = locatorConverter.getCfiFromLocator(epubBook, locator)
Timber.tag("NavDiag").d("Converted locator to CFI: $cfi")
if (cfi != null) { if (cfi != null) {
val targetChunk = locator.blockIndex / 20 val targetChunk = locator.blockIndex / 20
chunkTargetOverride = targetChunk chunkTargetOverride = targetChunk
if (currentChapterIndex != locator.chapterIndex) { if (currentChapterIndex != locator.chapterIndex) {
initialScrollTargetForChapter = null
currentScrollYPosition = 0 currentScrollYPosition = 0
currentScrollHeightValue = 0 currentScrollHeightValue = 0
currentChapterIndex = locator.chapterIndex currentChapterIndex = locator.chapterIndex
} else {
if (targetChunk > loadUpToChunkIndex) {
loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
}
initialScrollTargetForChapter = null
} }
cfiToLoad = cfi cfiToLoad = cfi
} else { } else {
@ -3661,7 +3695,15 @@ fun EpubReaderHost(
saveAutoScrollUseSlider(context, autoScrollUseSlider) saveAutoScrollUseSlider(context, autoScrollUseSlider)
}, },
isLocalMode = isAutoScrollLocal, 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, isTtsSessionActive = isTtsSessionActive,
ttsState = ttsState, ttsState = ttsState,
isProUser = isProUser, isProUser = isProUser,
hiddenTools = hiddenTools,
onOpenSlider = { onOpenSlider = {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { 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) { if (showDictionarySettingsSheet) {
DictionarySettingsDialog( DictionarySettingsDialog(
isVisible = true, isVisible = true,

View file

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

View file

@ -21,7 +21,15 @@ class OpdsRepository(context: Context) {
private const val KEY_CATALOGS_JSON = "opds_catalogs_json" private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
val sharedHttpClient: OkHttpClient by lazy { 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()) { 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(), "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) saveCatalogs(catalogs)
} }

View file

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

View file

@ -139,6 +139,8 @@ class VerticalPdfReaderState {
internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null
internal var snapToPageHandler: (suspend (Int) -> Unit)? = null internal var snapToPageHandler: (suspend (Int) -> Unit)? = null
internal var scrollByHandler: (suspend (Float) -> 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) { suspend fun scrollToPage(pageIndex: Int) {
scrollToPageHandler?.invoke(pageIndex) scrollToPageHandler?.invoke(pageIndex)
@ -151,6 +153,14 @@ class VerticalPdfReaderState {
suspend fun scrollBy(delta: Float) { suspend fun scrollBy(delta: Float) {
scrollByHandler?.invoke(delta) scrollByHandler?.invoke(delta)
} }
suspend fun scrollToTop() {
scrollToTopHandler?.invoke()
}
suspend fun scrollToBottom() {
scrollToBottomHandler?.invoke()
}
} }
@Composable @Composable
@ -244,6 +254,8 @@ internal fun PdfVerticalReader(
state.scrollToPageHandler = null state.scrollToPageHandler = null
state.snapToPageHandler = null state.snapToPageHandler = null
state.scrollByHandler = null state.scrollByHandler = null
state.scrollToTopHandler = null
state.scrollToBottomHandler = null
} }
} }
var globalEraserPosition by remember { mutableStateOf<Offset?>(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) } 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"), isItalic = sObj.optBoolean("i"),
isUnderline = sObj.optBoolean("u"), isUnderline = sObj.optBoolean("u"),
isStrikethrough = sObj.optBoolean("st"), 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), isItalic = obj.optBoolean("isItalic", false),
isUnderline = obj.optBoolean("isUnderline", false), isUnderline = obj.optBoolean("isUnderline", false),
isStrikeThrough = obj.optBoolean("isStrikeThrough", false), isStrikeThrough = obj.optBoolean("isStrikeThrough", false),
fontPath = obj.optString("fontPath", null).takeIf { !it.isNullOrBlank() }, fontPath = obj.optString("fontPath").takeIf { !it.isNullOrBlank() },
fontName = obj.optString("fontName", null).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 }, color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
text = obj.optString("text", ""), text = obj.optString("text", ""),
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)), 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_source_folder">Source Folder</string>
<string name="filter_read_status">Read Status</string> <string name="filter_read_status">Read Status</string>
<string name="clear_all">Clear All</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 --> <!-- OPDS -->
<string name="fab_add_catalog">Add Catalog</string> <string name="fab_add_catalog">Add Catalog</string>