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:
parent
db7e05ce63
commit
6a60aec0ef
17 changed files with 3045 additions and 2060 deletions
|
|
@ -774,15 +774,16 @@
|
||||||
document.addEventListener("DOMContentLoaded", initializeReaderContent);
|
document.addEventListener("DOMContentLoaded", initializeReaderContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.CURRENT_SEARCH_QUERY = "";
|
||||||
|
|
||||||
window.clearSearchHighlights = function () {
|
window.clearSearchHighlights = function () {
|
||||||
|
window.CURRENT_SEARCH_QUERY = "";
|
||||||
document.querySelectorAll("mark.search-highlight").forEach(function (el) {
|
document.querySelectorAll("mark.search-highlight").forEach(function (el) {
|
||||||
var parent = el.parentNode;
|
var parent = el.parentNode;
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
while (el.firstChild) {
|
while (el.firstChild) {
|
||||||
parent.insertBefore(el.firstChild, el);
|
parent.insertBefore(el.firstChild, el);
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.removeChild(el);
|
parent.removeChild(el);
|
||||||
parent.normalize();
|
parent.normalize();
|
||||||
}
|
}
|
||||||
|
|
@ -792,10 +793,12 @@
|
||||||
|
|
||||||
window.highlightAllOccurrences = function (query) {
|
window.highlightAllOccurrences = function (query) {
|
||||||
window.clearSearchHighlights();
|
window.clearSearchHighlights();
|
||||||
|
window.CURRENT_SEARCH_QUERY = query;
|
||||||
|
|
||||||
if (!query || query.length < 2) return "JS: Query too short for highlighting.";
|
if (!query || query.length < 2) return "JS: Query too short for highlighting.";
|
||||||
|
|
||||||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
var nodesToModify = [];
|
var nodesToModify =[];
|
||||||
|
|
||||||
while ((node = walker.nextNode())) {
|
while ((node = walker.nextNode())) {
|
||||||
if (node.nodeValue.toLowerCase().includes(query.toLowerCase())) {
|
if (node.nodeValue.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
|
@ -811,11 +814,9 @@
|
||||||
tempDiv.innerHTML = textNode.nodeValue.replace(regex, '<mark class="search-highlight">$1</mark>');
|
tempDiv.innerHTML = textNode.nodeValue.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||||
|
|
||||||
var parent = textNode.parentNode;
|
var parent = textNode.parentNode;
|
||||||
|
|
||||||
while (tempDiv.firstChild) {
|
while (tempDiv.firstChild) {
|
||||||
parent.insertBefore(tempDiv.firstChild, textNode);
|
parent.insertBefore(tempDiv.firstChild, textNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.removeChild(textNode);
|
parent.removeChild(textNode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -823,17 +824,37 @@
|
||||||
return "JS: Highlighted " + document.querySelectorAll("mark.search-highlight").length + " occurrences.";
|
return "JS: Highlighted " + document.querySelectorAll("mark.search-highlight").length + " occurrences.";
|
||||||
};
|
};
|
||||||
|
|
||||||
window.scrollToOccurrence = function (index) {
|
window.scrollToChunkOccurrence = function (chunkIndex, relativeIndex) {
|
||||||
var highlights = document.querySelectorAll("mark.search-highlight");
|
console.log("NavDiag: scrollToChunkOccurrence chunk=" + chunkIndex + ", relativeIdx=" + relativeIndex);
|
||||||
|
var chunkDiv = document.querySelector(`.chunk-container[data-chunk-index='${chunkIndex}']`);
|
||||||
|
|
||||||
if (highlights && index >= 0 && index < highlights.length) {
|
if (chunkDiv) {
|
||||||
var element = highlights[index];
|
let wasEmpty = false;
|
||||||
|
|
||||||
element.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
|
if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
|
||||||
return "JS: Scrolled to occurrence " + index;
|
console.log("NavDiag: Chunk was empty, restoring content before scrolling.");
|
||||||
|
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
|
||||||
|
chunkDiv.style.height = "";
|
||||||
|
wasEmpty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var highlights = chunkDiv.querySelectorAll("mark.search-highlight");
|
||||||
|
|
||||||
|
if ((wasEmpty || highlights.length === 0) && window.CURRENT_SEARCH_QUERY) {
|
||||||
|
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
|
||||||
|
highlights = chunkDiv.querySelectorAll("mark.search-highlight");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlights && highlights.length > 0) {
|
||||||
|
var targetIdx = (relativeIndex >= 0 && relativeIndex < highlights.length) ? relativeIndex : 0;
|
||||||
|
highlights[targetIdx].scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
|
||||||
|
return "JS: Scrolled to relative occurrence " + targetIdx + " in chunk " + chunkIndex;
|
||||||
|
} else {
|
||||||
|
chunkDiv.scrollIntoView({ behavior: "auto", block: "center" });
|
||||||
|
return "JS: No highlights in chunk, scrolled to chunk center.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return "JS: Chunk " + chunkIndex + " not found.";
|
||||||
return "JS: Occurrence " + index + " not found.";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.removeHighlight = function () {
|
window.removeHighlight = function () {
|
||||||
|
|
@ -1429,6 +1450,15 @@
|
||||||
|
|
||||||
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
|
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
|
||||||
if (chunkElement) {
|
if (chunkElement) {
|
||||||
|
if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
|
||||||
|
console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution.");
|
||||||
|
chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex];
|
||||||
|
chunkElement.style.height = "";
|
||||||
|
if (window.CURRENT_HIGHLIGHTS) {
|
||||||
|
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE);
|
let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE);
|
||||||
if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) {
|
if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) {
|
||||||
currentNode = elementsInChunk[indexInChunk];
|
currentNode = elementsInChunk[indexInChunk];
|
||||||
|
|
@ -1649,7 +1679,7 @@
|
||||||
cleanCfi = cfi.substring(cfi.indexOf('@') + 1);
|
cleanCfi = cfi.substring(cfi.indexOf('@') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("PosSaveDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
|
console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
|
||||||
|
|
||||||
if (!cleanCfi || !cleanCfi.startsWith('/')) {
|
if (!cleanCfi || !cleanCfi.startsWith('/')) {
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
||||||
|
|
@ -1719,6 +1749,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(window.scrollY - targetScrollY) > 1) {
|
if (Math.abs(window.scrollY - targetScrollY) > 1) {
|
||||||
|
console.log("NavDiag: Scrolling to targetY=" + targetScrollY);
|
||||||
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1947,6 +1978,9 @@
|
||||||
if (window.CURRENT_HIGHLIGHTS) {
|
if (window.CURRENT_HIGHLIGHTS) {
|
||||||
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
||||||
}
|
}
|
||||||
|
if (window.CURRENT_SEARCH_QUERY) {
|
||||||
|
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (div.innerHTML !== "") {
|
if (div.innerHTML !== "") {
|
||||||
|
|
@ -2005,6 +2039,10 @@
|
||||||
if (window.CURRENT_HIGHLIGHTS) {
|
if (window.CURRENT_HIGHLIGHTS) {
|
||||||
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.CURRENT_SEARCH_QUERY) {
|
||||||
|
window.highlightAllOccurrences(window.CURRENT_SEARCH_QUERY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.checkImagesForDiagnosis) {
|
if (window.checkImagesForDiagnosis) {
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1878,22 +1878,28 @@ fun LibraryFilterSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (syncedFolders.isNotEmpty()) {
|
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
|
||||||
Text(stringResource(R.string.filter_source_folder), style = MaterialTheme.typography.titleMedium)
|
Row(
|
||||||
Row(
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
) {
|
||||||
) {
|
FilterChip(
|
||||||
syncedFolders.forEach { folder ->
|
selected = "IN_APP_STORAGE" in currentFilters.sourceFolders,
|
||||||
FilterChip(
|
onClick = {
|
||||||
selected = folder.uriString in currentFilters.sourceFolders,
|
val newSet = if ("IN_APP_STORAGE" in currentFilters.sourceFolders) currentFilters.sourceFolders - "IN_APP_STORAGE" else currentFilters.sourceFolders + "IN_APP_STORAGE"
|
||||||
onClick = {
|
currentFilters = currentFilters.copy(sourceFolders = newSet)
|
||||||
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
|
},
|
||||||
currentFilters = currentFilters.copy(sourceFolders = newSet)
|
label = { Text(stringResource(R.string.filter_in_app_storage)) }
|
||||||
},
|
)
|
||||||
label = { Text(folder.name) }
|
syncedFolders.forEach { folder ->
|
||||||
)
|
FilterChip(
|
||||||
}
|
selected = folder.uriString in currentFilters.sourceFolders,
|
||||||
|
onClick = {
|
||||||
|
val newSet = if (folder.uriString in currentFilters.sourceFolders) currentFilters.sourceFolders - folder.uriString else currentFilters.sourceFolders + folder.uriString
|
||||||
|
currentFilters = currentFilters.copy(sourceFolders = newSet)
|
||||||
|
},
|
||||||
|
label = { Text(folder.name) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -69,6 +74,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
|
@ -131,6 +137,26 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
enum class ReaderTool(val title: String, val category: String) {
|
||||||
|
DICTIONARY("External Apps", "Top Bar"),
|
||||||
|
THEME("Theme Settings", "Top Bar"),
|
||||||
|
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||||
|
TOC("Sidebar", "Bottom Bar"),
|
||||||
|
FORMAT("Text Formatting", "Bottom Bar"),
|
||||||
|
SEARCH("Search", "Bottom Bar"),
|
||||||
|
AI_FEATURES("AI Features", "Bottom Bar"),
|
||||||
|
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
||||||
|
READING_MODE("Reading Mode", "Overflow Menu"),
|
||||||
|
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||||
|
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
||||||
|
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
|
||||||
|
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
||||||
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
|
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||||
|
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
||||||
|
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpubReaderTopBar(
|
fun EpubReaderTopBar(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
|
|
@ -158,6 +184,8 @@ fun EpubReaderTopBar(
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
onOpenVisualOptions: () -> Unit,
|
onOpenVisualOptions: () -> Unit,
|
||||||
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
onCustomizeTools: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onToggleReflow: (() -> Unit)? = null,
|
onToggleReflow: (() -> Unit)? = null,
|
||||||
onDeleteReflow: (() -> Unit)? = null,
|
onDeleteReflow: (() -> Unit)? = null,
|
||||||
|
|
@ -204,22 +232,26 @@ fun EpubReaderTopBar(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
|
||||||
text = stringResource(R.string.tooltip_dictionary),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_dictionary_desc),
|
text = stringResource(R.string.tooltip_dictionary),
|
||||||
onClick = onOpenDictionarySettings
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||||
) {
|
onClick = onOpenDictionarySettings
|
||||||
Icon(
|
) {
|
||||||
painter = painterResource(id = R.drawable.dictionary),
|
Icon(
|
||||||
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
|
painter = painterResource(id = R.drawable.dictionary),
|
||||||
)
|
contentDescription = stringResource(R.string.content_desc_dictionary_settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.THEME.name)) {
|
||||||
text = stringResource(R.string.tooltip_theme),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_theme_desc),
|
text = stringResource(R.string.tooltip_theme),
|
||||||
onClick = onOpenThemeSettings
|
description = stringResource(R.string.tooltip_theme_desc),
|
||||||
) {
|
onClick = onOpenThemeSettings
|
||||||
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
|
) {
|
||||||
|
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -235,6 +267,18 @@ fun EpubReaderTopBar(
|
||||||
expanded = showMoreMenu,
|
expanded = showMoreMenu,
|
||||||
onDismissRequest = { showMoreMenu = false }
|
onDismissRequest = { showMoreMenu = false }
|
||||||
) {
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Customize Toolbar") },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onCustomizeTools()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
if (onToggleReflow != null) {
|
if (onToggleReflow != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
|
text = { Text(stringResource(R.string.menu_view_original_pdf)) },
|
||||||
|
|
@ -274,125 +318,175 @@ fun EpubReaderTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenuItem(
|
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
DropdownMenuItem(
|
||||||
enabled = !isTtsActive,
|
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||||
onClick = {
|
enabled = !isTtsActive,
|
||||||
showMoreMenu = false
|
onClick = {
|
||||||
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
showMoreMenu = false
|
||||||
},
|
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
|
||||||
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
},
|
||||||
)
|
trailingIcon = {
|
||||||
DropdownMenuItem(
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
|
||||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
Icons.Default.Check,
|
||||||
enabled = !isTtsActive,
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
onClick = {
|
)
|
||||||
showMoreMenu = false
|
})
|
||||||
onChangeRenderMode(RenderMode.PAGINATED)
|
DropdownMenuItem(
|
||||||
},
|
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||||
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
enabled = !isTtsActive,
|
||||||
)
|
onClick = {
|
||||||
HorizontalDivider()
|
showMoreMenu = false
|
||||||
DropdownMenuItem(
|
onChangeRenderMode(RenderMode.PAGINATED)
|
||||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
},
|
||||||
onClick = {
|
trailingIcon = {
|
||||||
|
if (currentRenderMode == RenderMode.PAGINATED) Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_selected)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Text(
|
||||||
|
if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(
|
||||||
|
R.string.menu_bookmark_this_page
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, onClick = {
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
onToggleBookmark()
|
onToggleBookmark()
|
||||||
}
|
})
|
||||||
)
|
HorizontalDivider()
|
||||||
HorizontalDivider()
|
}
|
||||||
DropdownMenuItem(
|
if (!hiddenTools.contains(ReaderTool.TAP_TO_TURN.name)) {
|
||||||
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
DropdownMenuItem(
|
||||||
enabled = currentRenderMode == RenderMode.PAGINATED,
|
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
||||||
onClick = {
|
enabled = currentRenderMode == RenderMode.PAGINATED,
|
||||||
onToggleTapToNavigate(!tapToNavigateEnabled)
|
onClick = {
|
||||||
showMoreMenu = false
|
onToggleTapToNavigate(!tapToNavigateEnabled)
|
||||||
},
|
showMoreMenu = false
|
||||||
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
|
},
|
||||||
)
|
trailingIcon = {
|
||||||
HorizontalDivider()
|
if (tapToNavigateEnabled) Icon(
|
||||||
DropdownMenuItem(
|
Icons.Default.Check,
|
||||||
text = {
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.VOLUME_SCROLL.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
Text(
|
Text(
|
||||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(R.string.menu_volume_button_scrolling)
|
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) stringResource(
|
||||||
|
R.string.menu_volume_button_scrolling
|
||||||
|
)
|
||||||
else stringResource(R.string.menu_volume_button_page_turn)
|
else stringResource(R.string.menu_volume_button_page_turn)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleVolumeScroll(!volumeScrollEnabled)
|
onToggleVolumeScroll(!volumeScrollEnabled)
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
},
|
},
|
||||||
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
|
trailingIcon = {
|
||||||
)
|
if (volumeScrollEnabled) Icon(
|
||||||
HorizontalDivider()
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||||
DropdownMenuItem(
|
)
|
||||||
text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
|
})
|
||||||
enabled = currentRenderMode == RenderMode.PAGINATED,
|
HorizontalDivider()
|
||||||
onClick = {
|
}
|
||||||
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
|
if (!hiddenTools.contains(ReaderTool.PAGE_TURN_ANIM.name)) {
|
||||||
showMoreMenu = false
|
|
||||||
},
|
|
||||||
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
|
||||||
onClick = {
|
|
||||||
onToggleKeepScreenOn(!isKeepScreenOn)
|
|
||||||
showMoreMenu = false
|
|
||||||
},
|
|
||||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) }
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onOpenVisualOptions()
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
|
||||||
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onStartAutoScroll()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
onOpenDeviceVoiceSettings()
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
|
text = { Text(stringResource(R.string.menu_realistic_page_turns)) },
|
||||||
|
enabled = currentRenderMode == RenderMode.PAGINATED,
|
||||||
|
onClick = {
|
||||||
|
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (isPageTurnAnimationEnabled) Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||||
|
onClick = {
|
||||||
|
onToggleKeepScreenOn(!isKeepScreenOn)
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (isKeepScreenOn) Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
showMoreMenu = false
|
showMoreMenu = false
|
||||||
onOpenTtsSettings()
|
onOpenVisualOptions()
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
|
Icon(
|
||||||
}
|
Icons.Default.Visibility,
|
||||||
)
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||||
|
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onStartAutoScroll()
|
||||||
|
})
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenDeviceVoiceSettings()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.GraphicEq,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenTtsSettings()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.text_to_speech),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +512,7 @@ fun EpubReaderBottomBar(
|
||||||
onRecap: () -> Unit,
|
onRecap: () -> Unit,
|
||||||
onToggleTts: () -> Unit,
|
onToggleTts: () -> Unit,
|
||||||
onPlayPauseTts: () -> Unit,
|
onPlayPauseTts: () -> Unit,
|
||||||
|
hiddenTools: Set<String>,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -439,107 +534,131 @@ fun EpubReaderBottomBar(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
horizontalArrangement = Arrangement.SpaceAround
|
||||||
) {
|
) {
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
|
||||||
text = stringResource(R.string.tooltip_slider),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_slider_desc),
|
text = stringResource(R.string.tooltip_slider),
|
||||||
onClick = onOpenSlider,
|
description = stringResource(R.string.tooltip_slider_desc),
|
||||||
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
|
onClick = onOpenSlider,
|
||||||
) {
|
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
|
||||||
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.slider),
|
||||||
|
contentDescription = stringResource(R.string.content_desc_navigate_slider)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.TOC.name)) {
|
||||||
text = stringResource(R.string.tooltip_toc),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_toc_desc),
|
text = stringResource(R.string.tooltip_toc),
|
||||||
onClick = onOpenDrawer
|
description = stringResource(R.string.tooltip_toc_desc),
|
||||||
) {
|
onClick = onOpenDrawer
|
||||||
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_chapters_menu)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
|
||||||
text = stringResource(R.string.tooltip_format),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_format_desc),
|
text = stringResource(R.string.tooltip_format),
|
||||||
onClick = onToggleFormat
|
description = stringResource(R.string.tooltip_format_desc),
|
||||||
) {
|
onClick = onToggleFormat
|
||||||
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting))
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.format_size),
|
||||||
|
contentDescription = stringResource(R.string.content_desc_text_formatting)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TooltipIconButton(
|
if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
|
||||||
text = stringResource(R.string.tooltip_search),
|
TooltipIconButton(
|
||||||
description = stringResource(R.string.tooltip_search_desc),
|
text = stringResource(R.string.tooltip_search),
|
||||||
onClick = onToggleSearch
|
description = stringResource(R.string.tooltip_search_desc),
|
||||||
) {
|
onClick = onToggleSearch
|
||||||
Icon(imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.tooltip_search)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
|
if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
|
||||||
if (BuildConfig.FLAVOR != "oss") {
|
@Suppress(
|
||||||
Box {
|
"KotlinConstantConditions",
|
||||||
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
"SimplifyBooleanWithConstants"
|
||||||
TooltipIconButton(
|
) if (BuildConfig.FLAVOR != "oss") {
|
||||||
text = stringResource(R.string.tooltip_ai),
|
Box {
|
||||||
description = stringResource(R.string.tooltip_ai_desc),
|
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
||||||
onClick = { showAiFeaturesMenu = true }
|
TooltipIconButton(
|
||||||
) {
|
text = stringResource(R.string.tooltip_ai),
|
||||||
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
|
description = stringResource(R.string.tooltip_ai_desc),
|
||||||
}
|
onClick = { showAiFeaturesMenu = true }) {
|
||||||
DropdownMenu(
|
Icon(
|
||||||
expanded = showAiFeaturesMenu,
|
painter = painterResource(id = R.drawable.ai),
|
||||||
onDismissRequest = { showAiFeaturesMenu = false }
|
contentDescription = "AI Features"
|
||||||
) {
|
)
|
||||||
DropdownMenuItem(
|
}
|
||||||
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
|
DropdownMenu(
|
||||||
onClick = {
|
expanded = showAiFeaturesMenu,
|
||||||
showAiFeaturesMenu = false
|
onDismissRequest = { showAiFeaturesMenu = false }) {
|
||||||
onSummarize()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (BuildConfig.DEBUG && isProUser) {
|
|
||||||
HorizontalDivider()
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_recap_beta)) },
|
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
showAiFeaturesMenu = false
|
showAiFeaturesMenu = false
|
||||||
onRecap()
|
onSummarize()
|
||||||
}
|
})
|
||||||
)
|
if (BuildConfig.DEBUG && isProUser) {
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_recap_beta)) },
|
||||||
|
onClick = {
|
||||||
|
showAiFeaturesMenu = false
|
||||||
|
onRecap()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
|
||||||
TooltipIconButton(
|
Box {
|
||||||
text = if (isTtsSessionActive)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
stringResource(R.string.tooltip_tts_stop)
|
|
||||||
else
|
|
||||||
stringResource(R.string.tooltip_tts_start),
|
|
||||||
description = if (isTtsSessionActive)
|
|
||||||
stringResource(R.string.tooltip_tts_stop_desc)
|
|
||||||
else
|
|
||||||
stringResource(R.string.tooltip_tts_start_desc),
|
|
||||||
onClick = onToggleTts
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
|
|
||||||
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isTtsSessionActive) {
|
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = if (ttsState.isPlaying)
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
|
||||||
stringResource(R.string.tooltip_tts_pause)
|
else stringResource(R.string.tooltip_tts_start),
|
||||||
else
|
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
|
||||||
stringResource(R.string.tooltip_tts_resume),
|
else stringResource(R.string.tooltip_tts_start_desc),
|
||||||
description = if (ttsState.isPlaying)
|
onClick = onToggleTts
|
||||||
stringResource(R.string.tooltip_tts_pause_desc)
|
|
||||||
else
|
|
||||||
stringResource(R.string.tooltip_tts_resume_desc),
|
|
||||||
onClick = onPlayPauseTts,
|
|
||||||
enabled = !ttsState.isLoading
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
|
||||||
contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) else stringResource(R.string.content_desc_resume_tts)
|
id = R.drawable.text_to_speech
|
||||||
|
),
|
||||||
|
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
|
||||||
|
R.string.content_desc_start_tts
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isTtsSessionActive) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
|
||||||
|
else stringResource(R.string.tooltip_tts_resume),
|
||||||
|
description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
|
||||||
|
else stringResource(R.string.tooltip_tts_resume_desc),
|
||||||
|
onClick = onPlayPauseTts,
|
||||||
|
enabled = !ttsState.isLoading
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||||
|
contentDescription = if (ttsState.isPlaying) stringResource(
|
||||||
|
R.string.content_desc_pause_tts
|
||||||
|
) else stringResource(R.string.content_desc_resume_tts)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -885,6 +1004,7 @@ fun AutoScrollControls(
|
||||||
onLocalModeToggle: (Boolean) -> Unit,
|
onLocalModeToggle: (Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isTempPaused: Boolean = false,
|
isTempPaused: Boolean = false,
|
||||||
|
onScrollToTop: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val backgroundAlpha = 0.6f
|
val backgroundAlpha = 0.6f
|
||||||
|
|
||||||
|
|
@ -1018,6 +1138,19 @@ fun AutoScrollControls(
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (onScrollToTop != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onScrollToTop,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowUpward,
|
||||||
|
contentDescription = "Scroll to Top",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onMusicianModeToggle,
|
onClick = onMusicianModeToggle,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,8 @@ class VerticalPdfReaderState {
|
||||||
internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null
|
internal var scrollToPageHandler: (suspend (Int) -> Unit)? = null
|
||||||
internal var snapToPageHandler: (suspend (Int) -> Unit)? = null
|
internal var snapToPageHandler: (suspend (Int) -> Unit)? = null
|
||||||
internal var scrollByHandler: (suspend (Float) -> Unit)? = null
|
internal var scrollByHandler: (suspend (Float) -> Unit)? = null
|
||||||
|
internal var scrollToTopHandler: (suspend () -> Unit)? = null
|
||||||
|
internal var scrollToBottomHandler: (suspend () -> Unit)? = null
|
||||||
|
|
||||||
suspend fun scrollToPage(pageIndex: Int) {
|
suspend fun scrollToPage(pageIndex: Int) {
|
||||||
scrollToPageHandler?.invoke(pageIndex)
|
scrollToPageHandler?.invoke(pageIndex)
|
||||||
|
|
@ -151,6 +153,14 @@ class VerticalPdfReaderState {
|
||||||
suspend fun scrollBy(delta: Float) {
|
suspend fun scrollBy(delta: Float) {
|
||||||
scrollByHandler?.invoke(delta)
|
scrollByHandler?.invoke(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun scrollToTop() {
|
||||||
|
scrollToTopHandler?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun scrollToBottom() {
|
||||||
|
scrollToBottomHandler?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -244,6 +254,8 @@ internal fun PdfVerticalReader(
|
||||||
state.scrollToPageHandler = null
|
state.scrollToPageHandler = null
|
||||||
state.snapToPageHandler = null
|
state.snapToPageHandler = null
|
||||||
state.scrollByHandler = null
|
state.scrollByHandler = null
|
||||||
|
state.scrollToTopHandler = null
|
||||||
|
state.scrollToBottomHandler = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
@ -527,6 +539,23 @@ internal fun PdfVerticalReader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.scrollToTopHandler = {
|
||||||
|
panYAnimatable.animateTo(
|
||||||
|
targetValue = headerHeightPx,
|
||||||
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.scrollToBottomHandler = {
|
||||||
|
val currentZoom = zoomAnimatable.value
|
||||||
|
val zoomedDocHeight = totalDocHeight * currentZoom
|
||||||
|
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||||
|
panYAnimatable.animateTo(
|
||||||
|
targetValue = minPanY,
|
||||||
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
|
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue