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;
} }
return "JS: Occurrence " + index + " not found."; 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.";
}; };
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()
@ -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,12 +1878,19 @@ 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(
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 -> syncedFolders.forEach { folder ->
FilterChip( FilterChip(
selected = folder.uriString in currentFilters.sourceFolders, selected = folder.uriString in currentFilters.sourceFolders,
@ -1895,7 +1902,6 @@ fun LibraryFilterSheet(
) )
} }
} }
}
Text(stringResource(R.string.filter_read_status), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.filter_read_status), style = MaterialTheme.typography.titleMedium)
Row( Row(

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,6 +232,7 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary), text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc), description = stringResource(R.string.tooltip_dictionary_desc),
@ -214,6 +243,8 @@ fun EpubReaderTopBar(
contentDescription = stringResource(R.string.content_desc_dictionary_settings) contentDescription = stringResource(R.string.content_desc_dictionary_settings)
) )
} }
}
if (!hiddenTools.contains(ReaderTool.THEME.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_theme), text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc), description = stringResource(R.string.tooltip_theme_desc),
@ -221,6 +252,7 @@ fun EpubReaderTopBar(
) { ) {
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) }
TooltipIconButton( TooltipIconButton(
@ -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,6 +318,7 @@ fun EpubReaderTopBar(
) )
} }
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive, enabled = !isTtsActive,
@ -281,8 +326,12 @@ fun EpubReaderTopBar(
showMoreMenu = false showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL) onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
}, },
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
) )
})
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) }, text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive, enabled = !isTtsActive,
@ -290,17 +339,28 @@ fun EpubReaderTopBar(
showMoreMenu = false showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED) onChangeRenderMode(RenderMode.PAGINATED)
}, },
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) } trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
) )
})
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( }
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) }, if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
onClick = { 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()
}
if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = currentRenderMode == RenderMode.PAGINATED, enabled = currentRenderMode == RenderMode.PAGINATED,
@ -308,13 +368,21 @@ fun EpubReaderTopBar(
onToggleTapToNavigate(!tapToNavigateEnabled) onToggleTapToNavigate(!tapToNavigateEnabled)
showMoreMenu = false showMoreMenu = false
}, },
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } trailingIcon = {
if (tapToNavigateEnabled) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
) )
})
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { 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)
) )
}, },
@ -323,10 +391,15 @@ fun EpubReaderTopBar(
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(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
) )
})
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_realistic_page_turns)) }, text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
enabled = currentRenderMode == RenderMode.PAGINATED, enabled = currentRenderMode == RenderMode.PAGINATED,
@ -334,20 +407,30 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled) onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
showMoreMenu = false showMoreMenu = false
}, },
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } trailingIcon = {
if (isPageTurnAnimationEnabled) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
) )
})
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) }, text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = { onClick = {
onToggleKeepScreenOn(!isKeepScreenOn) onToggleKeepScreenOn(!isKeepScreenOn)
showMoreMenu = false showMoreMenu = false
}, },
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) } trailingIcon = {
if (isKeepScreenOn) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
) )
})
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) }, text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = { onClick = {
@ -355,22 +438,25 @@ fun EpubReaderTopBar(
onOpenVisualOptions() onOpenVisualOptions()
}, },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) Icon(
} Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(20.dp)
) )
})
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) }, text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL, enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = { onClick = {
showMoreMenu = false showMoreMenu = false
onStartAutoScroll() onStartAutoScroll()
} })
)
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
onClick = { onClick = {
@ -378,9 +464,12 @@ fun EpubReaderTopBar(
onOpenDeviceVoiceSettings() onOpenDeviceVoiceSettings()
}, },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) Icon(
} Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
) )
})
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
@ -390,7 +479,11 @@ fun EpubReaderTopBar(
onOpenTtsSettings() onOpenTtsSettings()
}, },
leadingIcon = { leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) Icon(
painter = painterResource(id = R.drawable.text_to_speech),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
} }
) )
} }
@ -400,6 +493,7 @@ fun EpubReaderTopBar(
} }
} }
} }
}
} }
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@ -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,58 +534,81 @@ fun EpubReaderBottomBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceAround
) { ) {
if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_slider), text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc), description = stringResource(R.string.tooltip_slider_desc),
onClick = onOpenSlider, onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL 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)
)
} }
}
if (!hiddenTools.contains(ReaderTool.TOC.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_toc), text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc), description = stringResource(R.string.tooltip_toc_desc),
onClick = onOpenDrawer 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)
)
} }
}
if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_format), text = stringResource(R.string.tooltip_format),
description = stringResource(R.string.tooltip_format_desc), description = stringResource(R.string.tooltip_format_desc),
onClick = onToggleFormat 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)
)
} }
}
if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_search), text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc), description = stringResource(R.string.tooltip_search_desc),
onClick = onToggleSearch 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(
"KotlinConstantConditions",
"SimplifyBooleanWithConstants"
) if (BuildConfig.FLAVOR != "oss") {
Box { Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) } var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_ai), text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc), description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true } onClick = { showAiFeaturesMenu = true }) {
) { Icon(
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features") painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features"
)
} }
DropdownMenu( DropdownMenu(
expanded = showAiFeaturesMenu, expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false } onDismissRequest = { showAiFeaturesMenu = false }) {
) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_chapter_summarization)) }, text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = { onClick = {
showAiFeaturesMenu = false showAiFeaturesMenu = false
onSummarize() onSummarize()
} })
)
if (BuildConfig.DEBUG && isProUser) { if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
@ -498,46 +616,46 @@ fun EpubReaderBottomBar(
onClick = { onClick = {
showAiFeaturesMenu = false showAiFeaturesMenu = false
onRecap() onRecap()
} })
)
} }
} }
} }
} }
}
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
Box { Box {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton( TooltipIconButton(
text = if (isTtsSessionActive) text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
else description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
stringResource(R.string.tooltip_tts_start), else stringResource(R.string.tooltip_tts_start_desc),
description = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop_desc)
else
stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts onClick = onToggleTts
) { ) {
Icon( Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_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) { if (isTtsSessionActive) {
TooltipIconButton( TooltipIconButton(
text = if (ttsState.isPlaying) text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
stringResource(R.string.tooltip_tts_pause) else stringResource(R.string.tooltip_tts_resume),
else description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
stringResource(R.string.tooltip_tts_resume), else stringResource(R.string.tooltip_tts_resume_desc),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = onPlayPauseTts, onClick = onPlayPauseTts,
enabled = !ttsState.isLoading enabled = !ttsState.isLoading
) { ) {
Icon( Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), 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) contentDescription = if (ttsState.isPlaying) stringResource(
R.string.content_desc_pause_tts
) else stringResource(R.string.content_desc_resume_tts)
) )
} }
} }
@ -546,6 +664,7 @@ fun EpubReaderBottomBar(
} }
} }
} }
}
} }
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@ -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)
@ -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 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) }

View file

@ -20,7 +20,7 @@
// PdfViewerScreen.kt // PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable", @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable",
"SimplifyBooleanWithConstants" "SimplifyBooleanWithConstants"
) ) @file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader.pdf package com.aryan.reader.pdf
@ -30,6 +30,8 @@ import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri import android.net.Uri
@ -91,6 +93,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -364,6 +367,42 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme" private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled" private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private fun loadPdfHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
}
private fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
}
enum class PdfReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
SEARCH("Search", "Bottom Bar"),
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
AI_FEATURES("AI Features", "Bottom Bar"),
EDIT_MODE("Edit Mode", "Bottom Bar"),
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
READING_MODE("Reading Mode", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
REFLOW("Text View (Reflow)", "Overflow Menu"),
SHARE("Share", "Overflow Menu"),
SAVE_COPY("Save Copy", "Overflow Menu"),
PRINT("Print", "Overflow Menu")
}
private fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> { private fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -1213,6 +1252,14 @@ fun PdfViewerScreen(
var showReindexDialog by remember { mutableStateOf<OcrLanguage?>(null) } var showReindexDialog by remember { mutableStateOf<OcrLanguage?>(null) }
var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) } var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) }
val onUpdateHiddenTools = { newSet: Set<String> ->
hiddenTools = newSet
savePdfHiddenTools(context, newSet)
}
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) { val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
{ action: () -> Unit -> { action: () -> Unit ->
if (hasSelectedOcrLanguage) { if (hasSelectedOcrLanguage) {
@ -3815,6 +3862,7 @@ fun PdfViewerScreen(
showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false
showAiDefinitionPopup -> showAiDefinitionPopup = false showAiDefinitionPopup -> showAiDefinitionPopup = false
showDictionaryUpsellDialog -> showDictionaryUpsellDialog = false showDictionaryUpsellDialog -> showDictionaryUpsellDialog = false
showCustomizeToolsSheet -> showCustomizeToolsSheet = false
isPageSliderVisible -> { isPageSliderVisible -> {
isPageSliderVisible = false isPageSliderVisible = false
showBars = true showBars = true
@ -5143,7 +5191,7 @@ fun PdfViewerScreen(
triggerAutoScrollTempPause(1000L) triggerAutoScrollTempPause(1000L)
coroutineScope.launch { coroutineScope.launch {
verticalReaderState.scrollToPage(0) verticalReaderState.scrollToTop()
} }
break break
} }
@ -5210,7 +5258,7 @@ fun PdfViewerScreen(
triggerAutoScrollTempPause(1000L) triggerAutoScrollTempPause(1000L)
coroutineScope.launch { coroutineScope.launch {
verticalReaderState.scrollToPage(totalPages - 1) verticalReaderState.scrollToBottom()
} }
break break
} }
@ -5555,6 +5603,7 @@ fun PdfViewerScreen(
.testTag("PageNumberIndicator") .testTag("PageNumberIndicator")
) )
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
TooltipIconButton( TooltipIconButton(
text = "Theme", text = "Theme",
description = "Theme Settings", description = "Theme Settings",
@ -5565,7 +5614,9 @@ fun PdfViewerScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
TooltipIconButton( TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan)
else stringResource(R.string.tooltip_lock_pan), else stringResource(R.string.tooltip_lock_pan),
@ -5585,7 +5636,9 @@ fun PdfViewerScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
if (!hiddenTools.contains(PdfReaderTool.FULL_SCREEN.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_fullscreen), text = stringResource(R.string.tooltip_fullscreen),
description = stringResource(R.string.tooltip_fullscreen_desc), description = stringResource(R.string.tooltip_fullscreen_desc),
@ -5599,7 +5652,9 @@ fun PdfViewerScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary), text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc), description = stringResource(R.string.tooltip_dictionary_desc),
@ -5610,6 +5665,7 @@ fun PdfViewerScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
TooltipIconButton( TooltipIconButton(
@ -5682,7 +5738,18 @@ fun PdfViewerScreen(
DropdownMenu( DropdownMenu(
expanded = showMoreMenu, expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }) { onDismissRequest = { showMoreMenu = false }) {
if (BuildConfig.IS_PRO) { DropdownMenuItem(
text = { Text("Customize Toolbar") },
onClick = {
showMoreMenu = false
showCustomizeToolsSheet = true
},
leadingIcon = {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
HorizontalDivider()
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("OCR Language") }, text = { Text("OCR Language") },
onClick = { onClick = {
@ -5693,6 +5760,7 @@ fun PdfViewerScreen(
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Reading Mode: Vertical scroll") }, text = { Text("Reading Mode: Vertical scroll") },
enabled = !isTtsSessionActive, enabled = !isTtsSessionActive,
@ -5725,6 +5793,8 @@ fun PdfViewerScreen(
} }
}) })
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Keep Screen On") }, text = { Text("Keep Screen On") },
onClick = { onClick = {
@ -5741,6 +5811,8 @@ fun PdfViewerScreen(
} }
}) })
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Auto Scroll") }, text = { Text("Auto Scroll") },
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL, enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
@ -5752,6 +5824,8 @@ fun PdfViewerScreen(
}) })
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("TTS Voice Settings") }, text = { Text("TTS Voice Settings") },
onClick = { onClick = {
@ -5764,7 +5838,8 @@ fun PdfViewerScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
}) }
)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
@ -5779,10 +5854,12 @@ fun PdfViewerScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
})
} }
)
}
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(text = { DropdownMenuItem(text = {
Text( Text(
if (isBookmarked) "Remove bookmark" if (isBookmarked) "Remove bookmark"
@ -5793,7 +5870,8 @@ fun PdfViewerScreen(
onBookmarkClick() onBookmarkClick()
}) })
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Insert Blank Page") }, text = { Text("Insert Blank Page") },
onClick = { onClick = {
@ -5815,9 +5893,9 @@ fun PdfViewerScreen(
) )
) )
} }
HorizontalDivider() HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
@ -5850,8 +5928,7 @@ fun PdfViewerScreen(
uiState.allRecentFiles.find { it.bookId == reflowBookId } uiState.allRecentFiles.find { it.bookId == reflowBookId }
if (item != null) { if (item != null) {
viewModel.switchToFileSeamlessly( viewModel.switchToFileSeamlessly(
item, item, resolvedPage
resolvedPage
) )
} else { } else {
viewModel.generateAndImportReflowFile( viewModel.generateAndImportReflowFile(
@ -5878,18 +5955,24 @@ fun PdfViewerScreen(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
}) })
HorizontalDivider() HorizontalDivider()
}
DropdownMenuItem(text = { Text("Share") }, onClick = { if (!hiddenTools.contains(PdfReaderTool.SHARE.name)) {
DropdownMenuItem(
text = { Text("Share") },
onClick = {
showMoreMenu = false showMoreMenu = false
showShareDialog = true showShareDialog = true
}, leadingIcon = { },
leadingIcon = {
Icon( Icon(
Icons.Default.Share, contentDescription = null Icons.Default.Share,
contentDescription = null
) )
}) }
if (uiState.selectedFileType == FileType.PDF) { )
}
if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Save copy to device") }, text = { Text("Save copy to device") },
onClick = { onClick = {
@ -5903,7 +5986,7 @@ fun PdfViewerScreen(
) )
}) })
} }
if (uiState.selectedFileType == FileType.PDF) { if (uiState.selectedFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Print") }, text = { Text("Print") },
onClick = { onClick = {
@ -6247,11 +6330,13 @@ fun PdfViewerScreen(
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceAround
) { ) {
// Slider Navigation Trigger // Slider Navigation Trigger
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_slider), text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc), description = stringResource(R.string.tooltip_slider_desc),
onClick = { onClick = {
val currentPage = if (displayMode == DisplayMode.PAGINATION) { val currentPage =
if (displayMode == DisplayMode.PAGINATION) {
pagerState.currentPage pagerState.currentPage
} else { } else {
verticalReaderState.currentPage verticalReaderState.currentPage
@ -6260,14 +6345,16 @@ fun PdfViewerScreen(
sliderCurrentPage = currentPage.toFloat() sliderCurrentPage = currentPage.toFloat()
isPageSliderVisible = true isPageSliderVisible = true
showBars = false showBars = false
}, enabled = !(ttsState.isPlaying || ttsState.isLoading) },
enabled = !(ttsState.isPlaying || ttsState.isLoading)
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.slider), painter = painterResource(id = R.drawable.slider),
contentDescription = "Navigate with slider" contentDescription = "Navigate with slider"
) )
} }
}
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_toc), text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc), description = stringResource(R.string.tooltip_toc_desc),
@ -6280,8 +6367,10 @@ fun PdfViewerScreen(
contentDescription = "Table of Contents" contentDescription = "Table of Contents"
) )
} }
}
// Search Button // Search Button
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_search), text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc), description = stringResource(R.string.tooltip_search_desc),
@ -6299,16 +6388,13 @@ fun PdfViewerScreen(
contentDescription = "Search" contentDescription = "Search"
) )
} }
}
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
TooltipIconButton( TooltipIconButton(
text = if (showAllTextHighlights) text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off)
stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
else description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc)
stringResource(R.string.tooltip_highlights), else stringResource(R.string.tooltip_highlights_desc),
description = if (showAllTextHighlights)
stringResource(R.string.tooltip_highlights_off_desc)
else
stringResource(R.string.tooltip_highlights_desc),
onClick = { onClick = {
val newState = !showAllTextHighlights val newState = !showAllTextHighlights
if (newState) { if (newState) {
@ -6331,9 +6417,10 @@ fun PdfViewerScreen(
) )
} }
} }
}
// AI feat // AI feat
if (BuildConfig.FLAVOR != "oss") { if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
Box { Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) } var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(
@ -6375,20 +6462,19 @@ fun PdfViewerScreen(
} }
// Edit Button // Edit Button
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
TooltipIconButton( TooltipIconButton(
text = if (isEditMode) text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit)
stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
else description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc)
stringResource(R.string.tooltip_edit_mode), else stringResource(R.string.tooltip_edit_mode_desc),
description = if (isEditMode)
stringResource(R.string.tooltip_edit_mode_exit_desc)
else
stringResource(R.string.tooltip_edit_mode_desc),
onClick = { onClick = {
val newEditMode = !isEditMode val newEditMode = !isEditMode
val currentActivePage = richTextController?.activePageIndex ?: -1 val currentActivePage =
richTextController?.activePageIndex ?: -1
Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)") Timber.tag("RichTextMigration")
.i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)")
if (!newEditMode && richTextController != null) { if (!newEditMode && richTextController != null) {
coroutineScope.launch { coroutineScope.launch {
@ -6401,25 +6487,22 @@ fun PdfViewerScreen(
isEditMode = newEditMode isEditMode = newEditMode
if (!newEditMode) showBars = true if (!newEditMode) showBars = true
} }) {
) {
Icon( Icon(
imageVector = Icons.Default.Edit, imageVector = Icons.Default.Edit,
contentDescription = "Toggle Editing Mode", contentDescription = "Toggle Editing Mode",
tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
// TTS // TTS
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton( TooltipIconButton(
text = if (isTtsSessionActive) text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
else description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
stringResource(R.string.tooltip_tts_start), else stringResource(R.string.tooltip_tts_start_desc),
description = if (isTtsSessionActive)
stringResource(R.string.tooltip_tts_stop_desc)
else
stringResource(R.string.tooltip_tts_start_desc),
onClick = { onClick = {
if (isTtsSessionActive) { if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS") Timber.d("TTS button clicked: Stopping TTS")
@ -6436,6 +6519,7 @@ fun PdfViewerScreen(
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS" contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
) )
} }
}
// TTS Pause/Resume Button // TTS Pause/Resume Button
if (isTtsSessionActive) { if (isTtsSessionActive) {
@ -7759,7 +7843,22 @@ fun PdfViewerScreen(
savePdfAutoScrollUseSlider(context, autoScrollUseSlider) savePdfAutoScrollUseSlider(context, autoScrollUseSlider)
}, },
isLocalMode = isAutoScrollLocal, isLocalMode = isAutoScrollLocal,
onLocalModeToggle = onToggleAutoScrollMode onLocalModeToggle = onToggleAutoScrollMode,
onScrollToTop = {
if (isAutoScrollPlaying) {
triggerAutoScrollTempPause(1000L)
}
coroutineScope.launch {
verticalReaderState.scrollToTop()
}
}
)
}
if (showCustomizeToolsSheet) {
PdfCustomizeToolsSheet(
hiddenTools = hiddenTools,
onUpdate = onUpdateHiddenTools,
onDismiss = { showCustomizeToolsSheet = false }
) )
} }
} }
@ -8412,3 +8511,74 @@ fun PdfSearchResultsList(
} }
} }
} }
@Composable
fun PdfCustomizeToolsSheet(
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()) {
PdfReaderTool.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

@ -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>