General improvements (#316)
* Added support for right text alignment in epub reader * Added tabs management to PDF navigation drawer * Implemented unified selection menu placement logic * Added reset functionality for toolbar customization * Improved highlight filtering in epub pagination * Migrated hardcoded UI strings to string resources * Extracted desktop application logic from Main.kt into modular files * Refactored Main.kt by extracting PDF and EPUB logic into specialized files * Added Spanish language support * Added system default option to app language selection
This commit is contained in:
parent
759d4b73a0
commit
056485a140
77 changed files with 9184 additions and 5947 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
@file:Suppress("UnstableApiUsage")
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
|
@ -18,6 +19,27 @@ if (localPropertiesFile.exists()) {
|
||||||
localPropertiesFile.inputStream().use { localProperties.load(it) }
|
localPropertiesFile.inputStream().use { localProperties.load(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun configuredAppLocaleTags(): Set<String> {
|
||||||
|
val localesConfig = file("src/main/res/xml/locales_config.xml")
|
||||||
|
val androidNamespace = "http://schemas.android.com/apk/res/android"
|
||||||
|
val document = DocumentBuilderFactory.newInstance()
|
||||||
|
.apply { isNamespaceAware = true }
|
||||||
|
.newDocumentBuilder()
|
||||||
|
.parse(localesConfig)
|
||||||
|
val localeNodes = document.getElementsByTagName("locale")
|
||||||
|
|
||||||
|
return buildSet {
|
||||||
|
for (index in 0 until localeNodes.length) {
|
||||||
|
val name = localeNodes.item(index)
|
||||||
|
.attributes
|
||||||
|
?.getNamedItemNS(androidNamespace, "name")
|
||||||
|
?.nodeValue
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (name != null) add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +55,7 @@ android {
|
||||||
versionCode = 51
|
versionCode = 51
|
||||||
versionName = "1.0.47"
|
versionName = "1.0.47"
|
||||||
|
|
||||||
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
|
resourceConfigurations += configuredAppLocaleTags()
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,131 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
window.setTextSelectionEnabled(true);
|
window.setTextSelectionEnabled(true);
|
||||||
|
installReaderSelectionBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installReaderSelectionBridge() {
|
||||||
|
if (window.__readerSelectionBridgeInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.__readerSelectionBridgeInstalled = true;
|
||||||
|
|
||||||
|
var pointerActive = false;
|
||||||
|
var lastPayloadJson = "";
|
||||||
|
|
||||||
|
function buildSelectionPayload() {
|
||||||
|
var selection = window.getSelection && window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedText = selection.toString().trim();
|
||||||
|
if (!selectedText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = selection.getRangeAt(0);
|
||||||
|
var viewportLeft = 0;
|
||||||
|
var viewportTop = 0;
|
||||||
|
var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||||
|
var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
|
var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []);
|
||||||
|
rects = rects.filter(function (rect) {
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||||
|
return rect.right >= viewportLeft &&
|
||||||
|
rect.left <= viewportRight &&
|
||||||
|
rect.bottom >= viewportTop &&
|
||||||
|
rect.top <= viewportBottom;
|
||||||
|
});
|
||||||
|
|
||||||
|
var rect = null;
|
||||||
|
if (rects.length > 0) {
|
||||||
|
var firstRect = rects[0];
|
||||||
|
rect = rects.reduce(function (acc, item) {
|
||||||
|
return {
|
||||||
|
left: Math.min(acc.left, item.left),
|
||||||
|
top: Math.min(acc.top, item.top),
|
||||||
|
right: Math.max(acc.right, item.right),
|
||||||
|
bottom: Math.max(acc.bottom, item.bottom)
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
left: firstRect.left,
|
||||||
|
top: firstRect.top,
|
||||||
|
right: firstRect.right,
|
||||||
|
bottom: firstRect.bottom
|
||||||
|
});
|
||||||
|
rect.width = rect.right - rect.left;
|
||||||
|
rect.height = rect.bottom - rect.top;
|
||||||
|
} else {
|
||||||
|
rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null;
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: selectedText,
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSelectionToBridge() {
|
||||||
|
if (pointerActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.ReaderSelectionBridge || !window.ReaderSelectionBridge.onSelectionChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = buildSelectionPayload();
|
||||||
|
if (!payload) {
|
||||||
|
lastPayloadJson = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadJson = JSON.stringify(payload);
|
||||||
|
if (payloadJson === lastPayloadJson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPayloadJson = payloadJson;
|
||||||
|
window.ReaderSelectionBridge.onSelectionChanged(payloadJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSelectionReport(delay) {
|
||||||
|
setTimeout(reportSelectionToBridge, delay == null ? 160 : delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPointerActive() {
|
||||||
|
pointerActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPointerReleased() {
|
||||||
|
pointerActive = false;
|
||||||
|
scheduleSelectionReport(80);
|
||||||
|
scheduleSelectionReport(220);
|
||||||
|
scheduleSelectionReport(520);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", function () {
|
||||||
|
if (!pointerActive) {
|
||||||
|
scheduleSelectionReport(160);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("pointerdown", markPointerActive, true);
|
||||||
|
document.addEventListener("pointerup", markPointerReleased, true);
|
||||||
|
document.addEventListener("pointercancel", markPointerReleased, true);
|
||||||
|
document.addEventListener("touchstart", markPointerActive, true);
|
||||||
|
document.addEventListener("touchend", markPointerReleased, true);
|
||||||
|
document.addEventListener("touchcancel", markPointerReleased, true);
|
||||||
|
document.addEventListener("mousedown", markPointerActive, true);
|
||||||
|
document.addEventListener("mouseup", markPointerReleased, true);
|
||||||
|
document.addEventListener("keyup", function () { scheduleSelectionReport(80); });
|
||||||
|
window.addEventListener("scroll", function () { scheduleSelectionReport(80); }, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.VIEWPORT_PADDING_TOP = 0;
|
window.VIEWPORT_PADDING_TOP = 0;
|
||||||
|
|
@ -766,6 +891,8 @@
|
||||||
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
|
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
|
||||||
if (textAlign === "left") {
|
if (textAlign === "left") {
|
||||||
alignCss = alignSelector + " { text-align: left !important; }";
|
alignCss = alignSelector + " { text-align: left !important; }";
|
||||||
|
} else if (textAlign === "right") {
|
||||||
|
alignCss = alignSelector + " { text-align: right !important; }";
|
||||||
} else if (textAlign === "justify") {
|
} else if (textAlign === "justify") {
|
||||||
alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
|
alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -53,6 +54,10 @@ fun AiSettingsScreen(
|
||||||
var pendingKey by remember { mutableStateOf("") }
|
var pendingKey by remember { mutableStateOf("") }
|
||||||
var showSaveConfirm by remember { mutableStateOf(false) }
|
var showSaveConfirm by remember { mutableStateOf(false) }
|
||||||
var providerToDelete by remember { mutableStateOf<String?>(null) }
|
var providerToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
val providerLabels = mapOf(
|
||||||
|
"gemini" to stringResource(R.string.provider_gemini),
|
||||||
|
"groq" to stringResource(R.string.provider_groq),
|
||||||
|
)
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
settings = loadAiByokSettings(context)
|
settings = loadAiByokSettings(context)
|
||||||
|
|
@ -67,10 +72,10 @@ fun AiSettingsScreen(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier.statusBarsPadding(),
|
||||||
topBar = {
|
topBar = {
|
||||||
CustomTopAppBar(
|
CustomTopAppBar(
|
||||||
title = { Text("AI keys and models") },
|
title = { Text(stringResource(R.string.ai_settings_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBackClick) {
|
IconButton(onClick = onBackClick) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -84,23 +89,23 @@ fun AiSettingsScreen(
|
||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.ai_settings_saved_keys), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
|
SavedKeyRow(providerLabels.getValue("gemini"), maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
|
||||||
SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
|
SavedKeyRow(providerLabels.getValue("groq"), maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.ai_settings_add_or_replace_key), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = providerMenuExpanded,
|
expanded = providerMenuExpanded,
|
||||||
onExpandedChange = { providerMenuExpanded = it },
|
onExpandedChange = { providerMenuExpanded = it },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = selectedProvider.replaceFirstChar { it.titlecase() },
|
value = providerLabels[selectedProvider].orEmpty(),
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text("Provider") },
|
label = { Text(stringResource(R.string.label_provider)) },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||||
)
|
)
|
||||||
|
|
@ -110,7 +115,7 @@ fun AiSettingsScreen(
|
||||||
) {
|
) {
|
||||||
listOf("gemini", "groq").forEach { provider ->
|
listOf("gemini", "groq").forEach { provider ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(provider.replaceFirstChar { it.titlecase() }) },
|
text = { Text(providerLabels[provider].orEmpty()) },
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedProvider = provider
|
selectedProvider = provider
|
||||||
providerMenuExpanded = false
|
providerMenuExpanded = false
|
||||||
|
|
@ -125,7 +130,7 @@ fun AiSettingsScreen(
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = pendingKey,
|
value = pendingKey,
|
||||||
onValueChange = { pendingKey = it },
|
onValueChange = { pendingKey = it },
|
||||||
label = { Text("API key") },
|
label = { Text(stringResource(R.string.label_api_key)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
|
@ -135,7 +140,7 @@ fun AiSettingsScreen(
|
||||||
enabled = pendingKey.isNotBlank(),
|
enabled = pendingKey.isNotBlank(),
|
||||||
modifier = Modifier.align(Alignment.End)
|
modifier = Modifier.align(Alignment.End)
|
||||||
) {
|
) {
|
||||||
Text("Save key")
|
Text(stringResource(R.string.ai_settings_save_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
@ -146,9 +151,9 @@ fun AiSettingsScreen(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.ai_settings_use_one_model), style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
"When off, each reader AI feature uses its own selected model.",
|
stringResource(R.string.ai_settings_use_one_model_desc),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -161,35 +166,35 @@ fun AiSettingsScreen(
|
||||||
|
|
||||||
if (settings.useOneModel) {
|
if (settings.useOneModel) {
|
||||||
ModelSelector(
|
ModelSelector(
|
||||||
title = "All AI features",
|
title = stringResource(R.string.ai_settings_all_features),
|
||||||
description = "Smart dictionary, summaries, and recaps all use this model.",
|
description = stringResource(R.string.ai_settings_all_features_desc),
|
||||||
selectedId = settings.modelForAll,
|
selectedId = settings.modelForAll,
|
||||||
onSelected = { updateModels(settings.copy(modelForAll = it)) }
|
onSelected = { updateModels(settings.copy(modelForAll = it)) }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ModelSelector(
|
ModelSelector(
|
||||||
title = "Smart dictionary",
|
title = stringResource(R.string.ai_settings_smart_dictionary),
|
||||||
description = "Used when defining selected words or phrases.",
|
description = stringResource(R.string.ai_settings_smart_dictionary_desc),
|
||||||
selectedId = settings.defineModel,
|
selectedId = settings.defineModel,
|
||||||
onSelected = { updateModels(settings.copy(defineModel = it)) }
|
onSelected = { updateModels(settings.copy(defineModel = it)) }
|
||||||
)
|
)
|
||||||
ModelSelector(
|
ModelSelector(
|
||||||
title = "Summaries",
|
title = stringResource(R.string.ai_settings_summaries),
|
||||||
description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.",
|
description = stringResource(R.string.ai_settings_summaries_desc),
|
||||||
selectedId = settings.summarizeModel,
|
selectedId = settings.summarizeModel,
|
||||||
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
|
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
|
||||||
)
|
)
|
||||||
ModelSelector(
|
ModelSelector(
|
||||||
title = "Recaps",
|
title = stringResource(R.string.ai_settings_recaps),
|
||||||
description = "Used for story recap generation.",
|
description = stringResource(R.string.ai_settings_recaps_desc),
|
||||||
selectedId = settings.recapModel,
|
selectedId = settings.recapModel,
|
||||||
onSelected = { updateModels(settings.copy(recapModel = it)) }
|
onSelected = { updateModels(settings.copy(recapModel = it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ModelSelector(
|
ModelSelector(
|
||||||
title = "Cloud TTS",
|
title = stringResource(R.string.credits_cloud_tts_title),
|
||||||
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
description = stringResource(R.string.ai_settings_cloud_tts_desc, GEMINI_CLOUD_TTS_MODEL),
|
||||||
selectedId = settings.ttsModel,
|
selectedId = settings.ttsModel,
|
||||||
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||||
onSelected = { updateModels(settings.copy(ttsModel = it)) }
|
onSelected = { updateModels(settings.copy(ttsModel = it)) }
|
||||||
|
|
@ -198,38 +203,40 @@ fun AiSettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSaveConfirm) {
|
if (showSaveConfirm) {
|
||||||
|
val providerLabel = providerLabels[selectedProvider].orEmpty()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showSaveConfirm = false },
|
onDismissRequest = { showSaveConfirm = false },
|
||||||
title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") },
|
title = { Text(stringResource(R.string.dialog_save_provider_key, providerLabel)) },
|
||||||
text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") },
|
text = { Text(stringResource(R.string.dialog_save_key_desc)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
saveAiByokKey(context, selectedProvider, pendingKey)
|
saveAiByokKey(context, selectedProvider, pendingKey)
|
||||||
pendingKey = ""
|
pendingKey = ""
|
||||||
showSaveConfirm = false
|
showSaveConfirm = false
|
||||||
refresh()
|
refresh()
|
||||||
}) { Text("Save") }
|
}) { Text(stringResource(R.string.action_save)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") }
|
TextButton(onClick = { showSaveConfirm = false }) { Text(stringResource(R.string.action_cancel)) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
providerToDelete?.let { provider ->
|
providerToDelete?.let { provider ->
|
||||||
|
val providerLabel = providerLabels[provider].orEmpty()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { providerToDelete = null },
|
onDismissRequest = { providerToDelete = null },
|
||||||
title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") },
|
title = { Text(stringResource(R.string.dialog_delete_provider_key, providerLabel)) },
|
||||||
text = { Text("Features using this provider will stop working until a new key is saved.") },
|
text = { Text(stringResource(R.string.dialog_delete_key_desc)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
deleteAiByokKey(context, provider)
|
deleteAiByokKey(context, provider)
|
||||||
providerToDelete = null
|
providerToDelete = null
|
||||||
refresh()
|
refresh()
|
||||||
}) { Text("Delete") }
|
}) { Text(stringResource(R.string.action_delete)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { providerToDelete = null }) { Text("Cancel") }
|
TextButton(onClick = { providerToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -241,14 +248,15 @@ private fun SavedKeyRow(
|
||||||
maskedKey: String,
|
maskedKey: String,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val noKeySaved = stringResource(R.string.ai_settings_no_key_saved)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = { Text(label) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(maskedKey.ifBlank { "No key saved" })
|
Text(maskedKey.ifBlank { noKeySaved })
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
|
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete $label key")
|
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.content_desc_delete_provider_key, label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -275,10 +283,10 @@ private fun ModelSelector(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = selected?.label ?: "No model selected",
|
value = selected?.label ?: stringResource(R.string.ai_settings_no_model_selected),
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text("Model") },
|
label = { Text(stringResource(R.string.label_model)) },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||||
)
|
)
|
||||||
|
|
@ -287,7 +295,7 @@ private fun ModelSelector(
|
||||||
onDismissRequest = { expanded = false }
|
onDismissRequest = { expanded = false }
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("No model selected") },
|
text = { Text(stringResource(R.string.ai_settings_no_model_selected)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelected("")
|
onSelected("")
|
||||||
expanded = false
|
expanded = false
|
||||||
|
|
|
||||||
|
|
@ -239,11 +239,19 @@ const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
|
||||||
|
|
||||||
enum class AiFeature { DEFINE, SUMMARIZE, RECAP }
|
enum class AiFeature { DEFINE, SUMMARIZE, RECAP }
|
||||||
|
|
||||||
private fun AiFeature.displayName(): String {
|
private fun AiFeature.displayName(context: Context): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
AiFeature.DEFINE -> "Smart dictionary"
|
AiFeature.DEFINE -> context.getString(R.string.ai_settings_smart_dictionary)
|
||||||
AiFeature.SUMMARIZE -> "Summaries"
|
AiFeature.SUMMARIZE -> context.getString(R.string.ai_settings_summaries)
|
||||||
AiFeature.RECAP -> "Recaps"
|
AiFeature.RECAP -> context.getString(R.string.ai_settings_recaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aiProviderDisplayName(context: Context, provider: String): String {
|
||||||
|
return when (provider) {
|
||||||
|
"gemini" -> context.getString(R.string.provider_gemini)
|
||||||
|
"groq" -> context.getString(R.string.provider_groq)
|
||||||
|
else -> provider.replaceFirstChar { it.titlecase(Locale.ROOT) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,7 +486,8 @@ data class CachedSummaryItem(
|
||||||
)
|
)
|
||||||
|
|
||||||
class SummaryCacheManager(context: Context) {
|
class SummaryCacheManager(context: Context) {
|
||||||
private val cacheDir = File(context.cacheDir, "chapter_summaries")
|
private val appContext = context.applicationContext
|
||||||
|
private val cacheDir = File(appContext.cacheDir, "chapter_summaries")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!cacheDir.exists()) {
|
if (!cacheDir.exists()) {
|
||||||
|
|
@ -529,7 +538,7 @@ class SummaryCacheManager(context: Context) {
|
||||||
val fullText = file.readText()
|
val fullText = file.readText()
|
||||||
val lines = fullText.lines()
|
val lines = fullText.lines()
|
||||||
|
|
||||||
val title = lines.firstOrNull()?.trim() ?: "Chapter ${index + 1}"
|
val title = lines.firstOrNull()?.trim() ?: appContext.getString(R.string.chapter_number_format, index + 1)
|
||||||
val summaryText = if (lines.size > 1) lines.drop(1).joinToString("\n") else ""
|
val summaryText = if (lines.size > 1) lines.drop(1).joinToString("\n") else ""
|
||||||
|
|
||||||
Timber.d("Cache Load: Ch $index, Title: $title")
|
Timber.d("Cache Load: Ch $index, Title: $title")
|
||||||
|
|
@ -914,6 +923,7 @@ fun AiDefinitionPopup(
|
||||||
}
|
}
|
||||||
|
|
||||||
val textToUse = styledContent.text
|
val textToUse = styledContent.text
|
||||||
|
val aiDefinitionTitle = stringResource(R.string.ai_definition_title)
|
||||||
|
|
||||||
if (textToUse.isNotBlank()) {
|
if (textToUse.isNotBlank()) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -937,7 +947,7 @@ fun AiDefinitionPopup(
|
||||||
val token = getAuthToken()
|
val token = getAuthToken()
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = chunks,
|
chunks = chunks,
|
||||||
bookTitle = "AI Definition",
|
bookTitle = aiDefinitionTitle,
|
||||||
chapterTitle = word,
|
chapterTitle = word,
|
||||||
coverImageUri = null,
|
coverImageUri = null,
|
||||||
ttsMode = loadTtsMode(context),
|
ttsMode = loadTtsMode(context),
|
||||||
|
|
@ -1178,8 +1188,8 @@ suspend fun fetchAiDefinition(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||||
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." }
|
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { context.getString(R.string.error_could_not_get_definition) }
|
||||||
onError("${responseCode}. ${errorDetail ?: context.getString(R.string.error_unknown_server)}")
|
onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorDetail ?: context.getString(R.string.error_unknown_server)))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Network error fetching AI definition: ${e.message}")
|
Timber.e(e, "Network error fetching AI definition: ${e.message}")
|
||||||
|
|
@ -1198,7 +1208,8 @@ fun countWords(text: String): Int {
|
||||||
private fun streamGeminiAiResponse(
|
private fun streamGeminiAiResponse(
|
||||||
connection: HttpURLConnection,
|
connection: HttpURLConnection,
|
||||||
onUpdate: (String) -> Unit,
|
onUpdate: (String) -> Unit,
|
||||||
onError: (String) -> Unit
|
onError: (String) -> Unit,
|
||||||
|
safetyError: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
var hasReceivedData = false
|
var hasReceivedData = false
|
||||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
|
@ -1243,7 +1254,7 @@ private fun streamGeminiAiResponse(
|
||||||
hasReceivedData = true
|
hasReceivedData = true
|
||||||
}
|
}
|
||||||
if (jsonResponse.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "SAFETY") {
|
if (jsonResponse.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "SAFETY") {
|
||||||
onError("Blocked for safety reasons.")
|
onError(safetyError)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Could not parse Gemini BYOK stream object")
|
Timber.w(e, "Could not parse Gemini BYOK stream object")
|
||||||
|
|
@ -1363,12 +1374,12 @@ suspend fun callByokTextAi(
|
||||||
val settings = loadAiByokSettings(context)
|
val settings = loadAiByokSettings(context)
|
||||||
val model = aiModelById(settings.modelIdFor(feature))
|
val model = aiModelById(settings.modelIdFor(feature))
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
onError("Choose a model for ${feature.displayName()} in AI key and model settings.")
|
onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context)))
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
val apiKey = settings.apiKeyFor(model.provider)
|
val apiKey = settings.apiKeyFor(model.provider)
|
||||||
if (apiKey.isBlank()) {
|
if (apiKey.isBlank()) {
|
||||||
onError("Add a ${model.provider.replaceFirstChar { it.titlecase(Locale.ROOT) }} API key in AI key and model settings.")
|
onError(context.getString(R.string.ai_error_add_provider_key, aiProviderDisplayName(context, model.provider)))
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1422,13 +1433,13 @@ suspend fun callByokTextAi(
|
||||||
val hasData = if (model.provider == "groq") {
|
val hasData = if (model.provider == "groq") {
|
||||||
streamGroqAiResponse(connection, onUpdate, onError)
|
streamGroqAiResponse(connection, onUpdate, onError)
|
||||||
} else {
|
} else {
|
||||||
streamGeminiAiResponse(connection, onUpdate, onError)
|
streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety))
|
||||||
}
|
}
|
||||||
if (!hasData) onError("The AI provider returned an empty response.")
|
if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response))
|
||||||
hasData
|
hasData
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||||
onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300)))
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -1454,16 +1465,16 @@ suspend fun callByokGeminiInlineAi(
|
||||||
val settings = loadAiByokSettings(context)
|
val settings = loadAiByokSettings(context)
|
||||||
val model = aiModelById(settings.modelIdFor(feature))
|
val model = aiModelById(settings.modelIdFor(feature))
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
onError("Choose a model for ${feature.displayName()} in AI key and model settings.")
|
onError(context.getString(R.string.ai_error_choose_model, feature.displayName(context)))
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
if (model.provider != "gemini") {
|
if (model.provider != "gemini") {
|
||||||
onError("This summary needs a Gemini model because the selected Groq models do not support PDF/image input.")
|
onError(context.getString(R.string.ai_error_gemini_required_for_image_summary))
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
val apiKey = settings.geminiKey.trim()
|
val apiKey = settings.geminiKey.trim()
|
||||||
if (apiKey.isBlank()) {
|
if (apiKey.isBlank()) {
|
||||||
onError("Add a Gemini API key in AI key and model settings.")
|
onError(context.getString(R.string.ai_error_add_provider_key, context.getString(R.string.provider_gemini)))
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1502,12 +1513,12 @@ suspend fun callByokGeminiInlineAi(
|
||||||
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
||||||
val responseCode = connection.responseCode
|
val responseCode = connection.responseCode
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||||
val hasData = streamGeminiAiResponse(connection, onUpdate, onError)
|
val hasData = streamGeminiAiResponse(connection, onUpdate, onError, context.getString(R.string.ai_error_blocked_safety))
|
||||||
if (!hasData) onError("The AI provider returned an empty response.")
|
if (!hasData) onError(context.getString(R.string.ai_error_provider_empty_response))
|
||||||
hasData
|
hasData
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||||
onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
onError(context.getString(R.string.ai_error_provider_error, responseCode, errorBody.orEmpty().take(300)))
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -1692,7 +1703,7 @@ suspend fun fetchRecap(
|
||||||
if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap))
|
if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap))
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||||
onError("${responseCode}. ${errorBody ?: ""}")
|
onError(context.getString(R.string.error_response_code_with_detail, responseCode, errorBody.orEmpty()))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Recap error: ${e.message}")
|
Timber.e(e, "Recap error: ${e.message}")
|
||||||
|
|
@ -2824,10 +2835,10 @@ fun ReaderThemePanel(
|
||||||
|
|
||||||
TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) {
|
TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) {
|
||||||
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }) {
|
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }) {
|
||||||
Text("Solid Colors", modifier = Modifier.padding(12.dp))
|
Text(stringResource(R.string.theme_solid_colors), modifier = Modifier.padding(12.dp))
|
||||||
}
|
}
|
||||||
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }) {
|
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }) {
|
||||||
Text("Textured", modifier = Modifier.padding(12.dp))
|
Text(stringResource(R.string.theme_textured), modifier = Modifier.padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2836,7 +2847,7 @@ fun ReaderThemePanel(
|
||||||
if (selectedTabIndex == 1) {
|
if (selectedTabIndex == 1) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text("Texture Transparency", style = MaterialTheme.typography.labelMedium)
|
Text(stringResource(R.string.theme_texture_transparency), style = MaterialTheme.typography.labelMedium)
|
||||||
Text("${(globalTextureTransparency * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
Text("${(globalTextureTransparency * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
|
|
@ -2894,7 +2905,7 @@ fun ReaderThemePanel(
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
IconButton(onClick = { editingTheme = null; builderIsTextured = selectedTabIndex == 1; showBuilder = true }) {
|
IconButton(onClick = { editingTheme = null; builderIsTextured = selectedTabIndex == 1; showBuilder = true }) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "New Theme", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.theme_new), tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2958,7 +2969,7 @@ private fun ThemeGridItem(
|
||||||
.clickable { onThemeSelected(theme.id) },
|
.clickable { onThemeSelected(theme.id) },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
|
@ -2967,9 +2978,9 @@ private fun ThemeGridItem(
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Default.Edit, stringResource(R.string.action_edit), Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
|
Icon(Icons.Default.Delete, stringResource(R.string.action_delete), Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2985,7 +2996,8 @@ fun ThemeBuilderView(
|
||||||
onCancel: () -> Unit
|
onCancel: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var name by remember { mutableStateOf(initialTheme?.name ?: if (isTexturedMode) "Custom Textured" else "Custom Solid") }
|
val defaultThemeName = stringResource(if (isTexturedMode) R.string.theme_custom_textured_default else R.string.theme_custom_solid_default)
|
||||||
|
var name by remember(initialTheme?.id, isTexturedMode, defaultThemeName) { mutableStateOf(initialTheme?.name ?: defaultThemeName) }
|
||||||
var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
|
var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
|
||||||
var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
|
var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
|
||||||
var editingColorType by remember { mutableStateOf<String?>(null) }
|
var editingColorType by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -3106,7 +3118,7 @@ private fun CustomTexturePickerSection(
|
||||||
onImportTexture: () -> Unit
|
onImportTexture: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Select Custom Texture", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
|
Text(stringResource(R.string.theme_select_custom_texture), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -3118,8 +3130,8 @@ private fun CustomTexturePickerSection(
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Import", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_import), tint = MaterialTheme.colorScheme.primary)
|
||||||
Text("Import", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.action_import), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1049,7 +1049,7 @@ fun DefaultTopAppBar(
|
||||||
}
|
}
|
||||||
}, actions = {
|
}, actions = {
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(onClick = onSettingsClick) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = onAppThemeClick) {
|
IconButton(onClick = onAppThemeClick) {
|
||||||
|
|
@ -1139,7 +1139,7 @@ fun DefaultTopAppBar(
|
||||||
|
|
||||||
if (!BuildConfig.IS_OFFLINE) {
|
if (!BuildConfig.IS_OFFLINE) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
text = { Text(stringResource(if (hideReaderAiFeatures) R.string.options_show_ai_in_reader else R.string.options_hide_ai_in_reader)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onToggleHideReaderAi()
|
onToggleHideReaderAi()
|
||||||
hideReaderAiFeatures = !hideReaderAiFeatures
|
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||||
|
|
@ -1374,7 +1374,7 @@ private fun AppDrawerContent(
|
||||||
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
label = { Text("Settings") },
|
label = { Text(stringResource(R.string.settings)) },
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = onSettingsClick,
|
onClick = onSettingsClick,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
|
@ -1391,7 +1391,7 @@ private fun AppDrawerContent(
|
||||||
if (isOss && !BuildConfig.IS_OFFLINE) {
|
if (isOss && !BuildConfig.IS_OFFLINE) {
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
|
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
|
||||||
label = { Text("AI keys and models") },
|
label = { Text(stringResource(R.string.ai_settings_title)) },
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = onAiSettingsClick,
|
onClick = onAiSettingsClick,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
|
@ -1855,7 +1855,7 @@ fun AppThemeBottomSheet(
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
|
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
|
||||||
Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.app_theme_text_brightness_light), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -1877,7 +1877,7 @@ fun AppThemeBottomSheet(
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.app_theme_text_brightness_dark), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -2184,38 +2184,30 @@ fun CreateAppThemeDialog(
|
||||||
@Composable
|
@Composable
|
||||||
fun LanguageSelectionDialog(onDismiss: () -> Unit) {
|
fun LanguageSelectionDialog(onDismiss: () -> Unit) {
|
||||||
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
||||||
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.language ?: "en" else "en"
|
val currentTag = if (!currentLocales.isEmpty) currentLocales.get(0)?.toLanguageTag() else null
|
||||||
|
|
||||||
val languages = listOf(
|
|
||||||
"en" to R.string.language_english_default,
|
|
||||||
"ar" to R.string.language_arabic,
|
|
||||||
"de" to R.string.language_german,
|
|
||||||
"tr" to R.string.language_turkish,
|
|
||||||
"fr" to R.string.language_french,
|
|
||||||
"ru" to R.string.language_russian
|
|
||||||
)
|
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.options_language)) },
|
title = { Text(stringResource(R.string.options_language)) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
languages.forEach { (tag, nameRes) ->
|
appLanguageSelectionOptions.forEach { language ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
AppCompatDelegate.setApplicationLocales(
|
val locales = language.tag?.let { tag ->
|
||||||
LocaleListCompat.forLanguageTags(tag)
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
)
|
} ?: LocaleListCompat.getEmptyLocaleList()
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = currentTag == tag, onClick = null)
|
RadioButton(selected = currentTag == language.tag, onClick = null)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Text(stringResource(nameRes))
|
Text(stringResource(language.labelRes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -723,7 +723,7 @@ fun LibraryScreenContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(onClick = onSettingsClick) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
|
@ -156,7 +157,7 @@ fun SettingsScreen(
|
||||||
title = { Text(settingsPage.title) },
|
title = { Text(settingsPage.title) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = ::navigateBackFromSettings) {
|
IconButton(onClick = ::navigateBackFromSettings) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -242,11 +243,11 @@ fun SettingsScreen(
|
||||||
SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context)
|
SharedSettingsAction.TEST_PANEL_DETECTION -> viewModel.testPanelDetection(context)
|
||||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context)
|
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION -> viewModel.testSpeechBubbleDetection(context)
|
||||||
SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context)
|
SharedSettingsAction.EXPORT_LOGS -> viewModel.exportLogsToFile(context)
|
||||||
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner("Debug actions remain in their existing menus.")
|
SharedSettingsAction.DEBUG_ACTIONS -> viewModel.showBanner(context.getString(R.string.debug_actions_existing_menus))
|
||||||
SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE)
|
SharedSettingsAction.HELP_FEEDBACK -> navController.navigate(AppDestinations.FEEDBACK_SCREEN_ROUTE)
|
||||||
SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE)
|
SharedSettingsAction.SUPPORT -> navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE)
|
||||||
SharedSettingsAction.ABOUT -> showAboutDialog = true
|
SharedSettingsAction.ABOUT -> showAboutDialog = true
|
||||||
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner("PDF-specific OCR, annotation, and tool settings remain in the PDF reader.")
|
SharedSettingsAction.PDF_READER_DEFAULTS -> viewModel.showBanner(context.getString(R.string.pdf_specific_settings_existing_reader))
|
||||||
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||||
SharedSettingsAction.READER_TOOLBAR,
|
SharedSettingsAction.READER_TOOLBAR,
|
||||||
SharedSettingsAction.TTS_REPLACEMENTS,
|
SharedSettingsAction.TTS_REPLACEMENTS,
|
||||||
|
|
@ -374,7 +375,7 @@ fun SettingsScreen(
|
||||||
onSpeakerChange = viewModel.ttsController::changeSpeaker,
|
onSpeakerChange = viewModel.ttsController::changeSpeaker,
|
||||||
isTtsActive = ttsState.isPlaying,
|
isTtsActive = ttsState.isPlaying,
|
||||||
getAuthToken = { viewModel.getAuthToken() },
|
getAuthToken = { viewModel.getAuthToken() },
|
||||||
bookTitle = "Reader defaults"
|
bookTitle = context.getString(R.string.reader_defaults)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,19 +396,23 @@ private fun RecentLimitDialog(
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Recent files limit") },
|
title = { Text(stringResource(R.string.options_recent_limit)) },
|
||||||
text = {
|
text = {
|
||||||
androidx.compose.foundation.layout.Column {
|
androidx.compose.foundation.layout.Column {
|
||||||
listOf(0, 10, 20, 50, 100).forEach { limit ->
|
listOf(0, 10, 20, 50, 100).forEach { limit ->
|
||||||
TextButton(onClick = { onSelect(limit) }) {
|
TextButton(onClick = { onSelect(limit) }) {
|
||||||
val label = if (limit == 0) "No limit" else "$limit files"
|
val label = if (limit == 0) {
|
||||||
Text(if (currentLimit == limit) "$label selected" else label)
|
stringResource(R.string.options_no_limit)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.options_files_limit, limit)
|
||||||
|
}
|
||||||
|
Text(if (currentLimit == limit) stringResource(R.string.option_selected_format, label) else label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -522,6 +527,7 @@ private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
|
||||||
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||||
|
AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
|
||||||
AndroidReaderTextAlign.DEFAULT,
|
AndroidReaderTextAlign.DEFAULT,
|
||||||
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||||
}
|
}
|
||||||
|
|
@ -539,6 +545,7 @@ private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
|
||||||
private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign {
|
private fun SharedReaderTextAlign.toAndroidTextAlign(): AndroidReaderTextAlign {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY
|
SharedReaderTextAlign.JUSTIFY -> AndroidReaderTextAlign.JUSTIFY
|
||||||
|
SharedReaderTextAlign.RIGHT -> AndroidReaderTextAlign.RIGHT
|
||||||
SharedReaderTextAlign.CENTER,
|
SharedReaderTextAlign.CENTER,
|
||||||
SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT
|
SharedReaderTextAlign.START -> AndroidReaderTextAlign.LEFT
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -550,10 +550,10 @@ fun FileInfoDialog(
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showRestoreConfirmation = false },
|
onDismissRequest = { showRestoreConfirmation = false },
|
||||||
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
||||||
title = { Text("Restore original metadata?") },
|
title = { Text(stringResource(R.string.dialog_restore_original_metadata)) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
stringResource(R.string.dialog_restore_original_metadata_desc)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|
@ -564,7 +564,7 @@ fun FileInfoDialog(
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text("Restore")
|
Text(stringResource(R.string.action_restore))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
|
|
@ -642,10 +642,10 @@ private fun BookMetadataInfoContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val provenance = when {
|
val provenance = when {
|
||||||
item.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
|
item.type == FileType.EPUB && hasMetadataChanges -> stringResource(R.string.metadata_provenance_epub_edited)
|
||||||
item.type == FileType.EPUB -> "Metadata from EPUB file"
|
item.type == FileType.EPUB -> stringResource(R.string.metadata_provenance_from_epub)
|
||||||
!item.customName.isNullOrBlank() -> "Display name changed in app"
|
!item.customName.isNullOrBlank() -> stringResource(R.string.metadata_provenance_display_name_changed)
|
||||||
else -> "Metadata from file"
|
else -> stringResource(R.string.metadata_provenance_from_file)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
provenance,
|
provenance,
|
||||||
|
|
@ -655,23 +655,23 @@ private fun BookMetadataInfoContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileInfoSection(title = "Metadata") {
|
FileInfoSection(title = stringResource(R.string.section_metadata)) {
|
||||||
InfoRowDetailed("Title", item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3)
|
InfoRowDetailed(stringResource(R.string.label_title), item.title?.takeIf { it.isNotBlank() } ?: item.displayName, maxLines = 3)
|
||||||
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||||
InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2)
|
InfoRowDetailed(stringResource(R.string.author), it, maxLines = 2)
|
||||||
}
|
}
|
||||||
item.seriesLabel()?.let {
|
item.seriesLabel()?.let {
|
||||||
InfoRowDetailed("Series", it, maxLines = 2)
|
InfoRowDetailed(stringResource(R.string.label_series), it, maxLines = 2)
|
||||||
}
|
}
|
||||||
InfoRowDetailed(stringResource(R.string.format), item.type.name)
|
InfoRowDetailed(stringResource(R.string.format), item.type.name)
|
||||||
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
|
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
|
||||||
InfoRowDetailed("Reading", item.readingProgressText(), maxLines = 2)
|
InfoRowDetailed(stringResource(R.string.label_reading), item.readingProgressText(), maxLines = 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
FileInfoSection(title = "File") {
|
FileInfoSection(title = stringResource(R.string.section_file)) {
|
||||||
InfoRowDetailed("File name", item.displayName, maxLines = 2)
|
InfoRowDetailed(stringResource(R.string.label_file_name_simple), item.displayName, maxLines = 2)
|
||||||
InfoRowDetailed(stringResource(R.string.added), formattedDate)
|
InfoRowDetailed(stringResource(R.string.added), formattedDate)
|
||||||
lastModifiedDate?.let { InfoRowDetailed("Modified", it) }
|
lastModifiedDate?.let { InfoRowDetailed(stringResource(R.string.label_modified), it) }
|
||||||
InfoRowDetailed(
|
InfoRowDetailed(
|
||||||
label = stringResource(R.string.location),
|
label = stringResource(R.string.location),
|
||||||
value = pathText,
|
value = pathText,
|
||||||
|
|
@ -686,7 +686,7 @@ private fun BookMetadataInfoContent(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Text("Summary", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.label_summary), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
ExpandableSummaryText(summary, collapsedMaxLines = 4)
|
ExpandableSummaryText(summary, collapsedMaxLines = 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -698,7 +698,7 @@ private fun BookMetadataInfoContent(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Library tags", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.label_library_tags), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) }
|
TextButton(onClick = onOpenTags) { Text(stringResource(R.string.action_add_edit)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,11 +732,11 @@ private fun BookMetadataEditContent(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text("Editable metadata", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.label_editable_metadata), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = titleInput,
|
value = titleInput,
|
||||||
onValueChange = onTitleChange,
|
onValueChange = onTitleChange,
|
||||||
label = { Text("Title") },
|
label = { Text(stringResource(R.string.label_title)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 3
|
maxLines = 3
|
||||||
)
|
)
|
||||||
|
|
@ -751,7 +751,7 @@ private fun BookMetadataEditContent(
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = seriesInput,
|
value = seriesInput,
|
||||||
onValueChange = onSeriesChange,
|
onValueChange = onSeriesChange,
|
||||||
label = { Text("Series") },
|
label = { Text(stringResource(R.string.label_series)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
|
|
@ -767,7 +767,7 @@ private fun BookMetadataEditContent(
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = descriptionInput,
|
value = descriptionInput,
|
||||||
onValueChange = onDescriptionChange,
|
onValueChange = onDescriptionChange,
|
||||||
label = { Text("Summary") },
|
label = { Text(stringResource(R.string.label_summary)) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 128.dp),
|
.heightIn(min = 128.dp),
|
||||||
|
|
@ -789,16 +789,16 @@ private fun BookDisplayNameEditContent(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text("Display name", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.label_display_name), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = displayNameInput,
|
value = displayNameInput,
|
||||||
onValueChange = onDisplayNameChange,
|
onValueChange = onDisplayNameChange,
|
||||||
label = { Text("Name shown in Reader") },
|
label = { Text(stringResource(R.string.label_name_shown_in_reader)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 3
|
maxLines = 3
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Original file: $originalFileName",
|
stringResource(R.string.original_file_format, originalFileName),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|
@ -833,7 +833,7 @@ private fun FileInfoBottomBar(
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Restore")
|
Text(stringResource(R.string.action_restore))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextButton(onClick = onCancel) {
|
TextButton(onClick = onCancel) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
|
@ -49,6 +50,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
|
@ -104,12 +106,12 @@ fun TtsWordReplacementsSheet(
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "TTS Word Replacements",
|
text = stringResource(R.string.menu_tts_word_replacements),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book",
|
text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.tts_replacements_current_book),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -117,7 +119,7 @@ fun TtsWordReplacementsSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +132,7 @@ fun TtsWordReplacementsSheet(
|
||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
editTarget = null
|
editTarget = null
|
||||||
},
|
},
|
||||||
text = { Text("Global") },
|
text = { Text(stringResource(R.string.tts_replacements_tab_global)) },
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == 1,
|
selected = selectedTab == 1,
|
||||||
|
|
@ -138,7 +140,7 @@ fun TtsWordReplacementsSheet(
|
||||||
selectedTab = 1
|
selectedTab = 1
|
||||||
editTarget = null
|
editTarget = null
|
||||||
},
|
},
|
||||||
text = { Text("This book") },
|
text = { Text(stringResource(R.string.tts_replacements_tab_this_book)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,8 +181,8 @@ private fun GlobalReplacementTab(
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Enable replacements") },
|
headlineContent = { Text(stringResource(R.string.tts_replacements_enable)) },
|
||||||
supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") },
|
supportingContent = { Text(stringResource(R.string.tts_replacements_enable_desc)) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = preferences.isEnabled,
|
checked = preferences.isEnabled,
|
||||||
|
|
@ -206,7 +208,7 @@ private fun GlobalReplacementTab(
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Add rule")
|
Text(stringResource(R.string.tts_replacements_add_rule))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (editTarget != null) {
|
if (editTarget != null) {
|
||||||
|
|
@ -229,7 +231,7 @@ private fun GlobalReplacementTab(
|
||||||
item {
|
item {
|
||||||
ReplacementRuleList(
|
ReplacementRuleList(
|
||||||
rules = preferences.globalRules,
|
rules = preferences.globalRules,
|
||||||
emptyText = "No global replacement rules yet.",
|
emptyTextRes = R.string.tts_replacements_empty_global,
|
||||||
onToggle = { rule, enabled ->
|
onToggle = { rule, enabled ->
|
||||||
onPreferencesChange(
|
onPreferencesChange(
|
||||||
preferences.copy(
|
preferences.copy(
|
||||||
|
|
@ -297,7 +299,7 @@ private fun BookReplacementTab(
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Add book rule")
|
Text(stringResource(R.string.tts_replacements_add_book_rule))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (editTarget != null) {
|
if (editTarget != null) {
|
||||||
|
|
@ -320,7 +322,7 @@ private fun BookReplacementTab(
|
||||||
item {
|
item {
|
||||||
ReplacementRuleList(
|
ReplacementRuleList(
|
||||||
rules = localRules,
|
rules = localRules,
|
||||||
emptyText = "No book-specific rules yet.",
|
emptyTextRes = R.string.tts_replacements_empty_book,
|
||||||
onToggle = { rule, enabled ->
|
onToggle = { rule, enabled ->
|
||||||
onPreferencesChange(
|
onPreferencesChange(
|
||||||
preferences.withBookRules(
|
preferences.withBookRules(
|
||||||
|
|
@ -349,8 +351,8 @@ private fun BookSettingsSwitches(
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Use global rules here") },
|
headlineContent = { Text(stringResource(R.string.tts_replacements_use_global_here)) },
|
||||||
supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") },
|
supportingContent = { Text(stringResource(R.string.tts_replacements_use_global_here_desc)) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = settings.globalRulesEnabled,
|
checked = settings.globalRulesEnabled,
|
||||||
|
|
@ -360,8 +362,8 @@ private fun BookSettingsSwitches(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Enable book rules") },
|
headlineContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules)) },
|
||||||
supportingContent = { Text("Local rules run after global rules.") },
|
supportingContent = { Text(stringResource(R.string.tts_replacements_enable_book_rules_desc)) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = settings.localRulesEnabled,
|
checked = settings.localRulesEnabled,
|
||||||
|
|
@ -381,13 +383,13 @@ private fun InheritedGlobalRules(
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Inherited global rules",
|
text = stringResource(R.string.tts_replacements_inherited_global_rules),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
if (globalRules.isEmpty()) {
|
if (globalRules.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "No global rules to inherit.",
|
text = stringResource(R.string.tts_replacements_no_global_rules),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -395,9 +397,10 @@ private fun InheritedGlobalRules(
|
||||||
}
|
}
|
||||||
globalRules.forEach { rule ->
|
globalRules.forEach { rule ->
|
||||||
val enabledHere = rule.id !in settings.disabledGlobalRuleIds
|
val enabledHere = rule.id !in settings.disabledGlobalRuleIds
|
||||||
|
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(rule.summaryText()) },
|
headlineContent = { Text(rule.summaryText(silenceLabel)) },
|
||||||
supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") },
|
supportingContent = { Text(stringResource(if (enabledHere) R.string.tts_replacements_allowed_in_book else R.string.tts_replacements_disabled_for_book)) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = enabledHere,
|
checked = enabledHere,
|
||||||
|
|
@ -422,15 +425,16 @@ private fun SuggestionChips(
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Suggestions",
|
text = stringResource(R.string.tts_replacements_suggestions),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
|
items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
|
||||||
|
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { onSuggestionClick(suggestion) },
|
onClick = { onSuggestionClick(suggestion) },
|
||||||
label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
label = { Text(suggestion.summaryText(silenceLabel), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -456,8 +460,9 @@ private fun RuleEditorCard(
|
||||||
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
|
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
|
||||||
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
|
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
|
||||||
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
|
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
|
||||||
var previewInput by remember(initial.id) {
|
val defaultPreviewInput = stringResource(R.string.tts_replacements_preview_default)
|
||||||
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.")
|
var previewInput by remember(initial.id, defaultPreviewInput) {
|
||||||
|
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
val draft = ReaderTtsReplacementRule(
|
val draft = ReaderTtsReplacementRule(
|
||||||
|
|
@ -490,7 +495,7 @@ private fun RuleEditorCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (seedRule == null) "New replacement" else "Edit replacement",
|
text = stringResource(if (seedRule == null) R.string.tts_replacements_new_replacement else R.string.tts_replacements_edit_replacement),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
@ -498,7 +503,7 @@ private fun RuleEditorCard(
|
||||||
value = from,
|
value = from,
|
||||||
onValueChange = { from = it },
|
onValueChange = { from = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Replace") },
|
label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
|
||||||
singleLine = !isRegex,
|
singleLine = !isRegex,
|
||||||
isError = !validation.isValid,
|
isError = !validation.isValid,
|
||||||
supportingText = if (validation.message != null) {
|
supportingText = if (validation.message != null) {
|
||||||
|
|
@ -515,7 +520,7 @@ private fun RuleEditorCard(
|
||||||
value = to,
|
value = to,
|
||||||
onValueChange = { to = it },
|
onValueChange = { to = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Speak as") },
|
label = { Text(stringResource(R.string.tts_replacements_label_speak_as)) },
|
||||||
singleLine = !isRegex,
|
singleLine = !isRegex,
|
||||||
)
|
)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
@ -523,7 +528,7 @@ private fun RuleEditorCard(
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = enabled,
|
selected = enabled,
|
||||||
onClick = { enabled = !enabled },
|
onClick = { enabled = !enabled },
|
||||||
label = { Text("Enabled") },
|
label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
|
||||||
leadingIcon = if (enabled) {
|
leadingIcon = if (enabled) {
|
||||||
{ Icon(Icons.Default.Check, contentDescription = null) }
|
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -535,21 +540,21 @@ private fun RuleEditorCard(
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = isRegex,
|
selected = isRegex,
|
||||||
onClick = { isRegex = !isRegex },
|
onClick = { isRegex = !isRegex },
|
||||||
label = { Text("Regex") },
|
label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = wholeWord,
|
selected = wholeWord,
|
||||||
onClick = { wholeWord = !wholeWord },
|
onClick = { wholeWord = !wholeWord },
|
||||||
label = { Text("Whole word") },
|
label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = matchCase,
|
selected = matchCase,
|
||||||
onClick = { matchCase = !matchCase },
|
onClick = { matchCase = !matchCase },
|
||||||
label = { Text("Match case") },
|
label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +562,7 @@ private fun RuleEditorCard(
|
||||||
value = previewInput,
|
value = previewInput,
|
||||||
onValueChange = { previewInput = it },
|
onValueChange = { previewInput = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Preview input") },
|
label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -570,14 +575,14 @@ private fun RuleEditorCard(
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onCancel) {
|
TextButton(onClick = onCancel) {
|
||||||
Text("Cancel")
|
Text(stringResource(R.string.action_cancel))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = { onSave(draft) },
|
onClick = { onSave(draft) },
|
||||||
enabled = validation.isValid,
|
enabled = validation.isValid,
|
||||||
) {
|
) {
|
||||||
Text("Save")
|
Text(stringResource(R.string.action_save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -587,14 +592,14 @@ private fun RuleEditorCard(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplacementRuleList(
|
private fun ReplacementRuleList(
|
||||||
rules: List<ReaderTtsReplacementRule>,
|
rules: List<ReaderTtsReplacementRule>,
|
||||||
emptyText: String,
|
@StringRes emptyTextRes: Int,
|
||||||
onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
|
onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
|
||||||
onEdit: (ReaderTtsReplacementRule) -> Unit,
|
onEdit: (ReaderTtsReplacementRule) -> Unit,
|
||||||
onDelete: (ReaderTtsReplacementRule) -> Unit,
|
onDelete: (ReaderTtsReplacementRule) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Rules",
|
text = stringResource(R.string.tts_replacements_rules),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
@ -606,7 +611,7 @@ private fun ReplacementRuleList(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = emptyText,
|
text = stringResource(emptyTextRes),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -614,10 +619,11 @@ private fun ReplacementRuleList(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rules.forEach { rule ->
|
rules.forEach { rule ->
|
||||||
|
val silenceLabel = stringResource(R.string.tts_replacements_silence)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = rule.summaryText(),
|
text = rule.summaryText(silenceLabel),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
@ -632,10 +638,10 @@ private fun ReplacementRuleList(
|
||||||
onCheckedChange = { onToggle(rule, it) },
|
onCheckedChange = { onToggle(rule, it) },
|
||||||
)
|
)
|
||||||
IconButton(onClick = { onEdit(rule) }) {
|
IconButton(onClick = { onEdit(rule) }) {
|
||||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onDelete(rule) }) {
|
IconButton(onClick = { onDelete(rule) }) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -648,16 +654,21 @@ private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsRep
|
||||||
return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
|
return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ReaderTtsReplacementRule.summaryText(): String {
|
private fun ReaderTtsReplacementRule.summaryText(silenceLabel: String): String {
|
||||||
val replacement = to.ifBlank { "silence" }
|
val replacement = to.ifBlank { silenceLabel }
|
||||||
return "$from -> $replacement"
|
return "$from -> $replacement"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun ReaderTtsReplacementRule.optionSummary(): String {
|
private fun ReaderTtsReplacementRule.optionSummary(): String {
|
||||||
|
val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
|
||||||
|
val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
|
||||||
|
val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
|
||||||
|
val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
|
||||||
val parts = buildList {
|
val parts = buildList {
|
||||||
add(if (isRegex) "Regex" else "Plain text")
|
add(if (isRegex) regexLabel else plainTextLabel)
|
||||||
if (wholeWord) add("whole word")
|
if (wholeWord) add(wholeWordLabel)
|
||||||
if (matchCase) add("case-sensitive")
|
if (matchCase) add(caseSensitiveLabel)
|
||||||
}
|
}
|
||||||
return parts.joinToString(" - ")
|
return parts.joinToString(" - ")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,25 @@ package com.aryan.reader
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
data class AppLanguageOption(
|
||||||
|
val tag: String?,
|
||||||
|
@StringRes val labelRes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
val systemAppLanguageOption = AppLanguageOption(null, R.string.language_system_default)
|
||||||
|
|
||||||
|
val supportedAppLanguageOptions = listOf(
|
||||||
|
AppLanguageOption("en", R.string.language_english),
|
||||||
|
AppLanguageOption("ar", R.string.language_arabic),
|
||||||
|
AppLanguageOption("de", R.string.language_german),
|
||||||
|
AppLanguageOption("tr", R.string.language_turkish),
|
||||||
|
AppLanguageOption("fr", R.string.language_french),
|
||||||
|
AppLanguageOption("ru", R.string.language_russian),
|
||||||
|
AppLanguageOption("es", R.string.language_spanish)
|
||||||
|
)
|
||||||
|
|
||||||
|
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
||||||
|
|
||||||
val AddBooksSource.labelRes: Int
|
val AddBooksSource.labelRes: Int
|
||||||
@StringRes get() = when (this) {
|
@StringRes get() = when (this) {
|
||||||
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
|
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import android.widget.Toast
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -46,6 +48,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
|
@ -69,6 +72,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -83,6 +87,10 @@ import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.getReaderTextureDataUri
|
import com.aryan.reader.getReaderTextureDataUri
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||||
|
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -1026,6 +1034,8 @@ fun ChapterWebView(
|
||||||
|
|
||||||
// Custom Selection Menu Popup
|
// Custom Selection Menu Popup
|
||||||
customMenuState?.let { state ->
|
customMenuState?.let { state ->
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp)
|
||||||
val popupPositionProvider =
|
val popupPositionProvider =
|
||||||
remember(state.selectionBounds, density, state.isExistingHighlight) {
|
remember(state.selectionBounds, density, state.isExistingHighlight) {
|
||||||
object : PopupPositionProvider {
|
object : PopupPositionProvider {
|
||||||
|
|
@ -1035,30 +1045,23 @@ fun ChapterWebView(
|
||||||
layoutDirection: LayoutDirection,
|
layoutDirection: LayoutDirection,
|
||||||
popupContentSize: IntSize
|
popupContentSize: IntSize
|
||||||
): IntOffset {
|
): IntOffset {
|
||||||
val topMargin = with(density) { 16.dp.toPx() }.toInt()
|
val marginPx = with(density) { 16.dp.toPx() }
|
||||||
val bottomMargin = with(density) {
|
val gapPx = with(density) {
|
||||||
if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx()
|
if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx()
|
||||||
}.toInt()
|
|
||||||
|
|
||||||
var x = state.selectionBounds.centerX() - popupContentSize.width / 2
|
|
||||||
|
|
||||||
var y = state.selectionBounds.top - popupContentSize.height - topMargin
|
|
||||||
if (y < with(density) { 24.dp.toPx() }.toInt()) {
|
|
||||||
y = state.selectionBounds.bottom + bottomMargin
|
|
||||||
}
|
}
|
||||||
if (x < 0) x = 0
|
val placement = sharedSelectionMenuPlacement(
|
||||||
if (x + popupContentSize.width > windowSize.width) {
|
viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height),
|
||||||
x = windowSize.width - popupContentSize.width
|
popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height),
|
||||||
}
|
selection = SharedSelectionMenuRect(
|
||||||
if (y + popupContentSize.height > windowSize.height) {
|
left = state.selectionBounds.left.toFloat(),
|
||||||
y = windowSize.height - popupContentSize.height
|
top = state.selectionBounds.top.toFloat(),
|
||||||
}
|
right = state.selectionBounds.right.toFloat(),
|
||||||
if (y < 0) y = 0
|
bottom = state.selectionBounds.bottom.toFloat()
|
||||||
|
),
|
||||||
return IntOffset(
|
marginPx = marginPx,
|
||||||
x.coerceIn(0, windowSize.width - popupContentSize.width),
|
gapPx = gapPx
|
||||||
y.coerceIn(0, windowSize.height - popupContentSize.height)
|
|
||||||
)
|
)
|
||||||
|
return IntOffset(placement.x, placement.y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1075,11 +1078,14 @@ fun ChapterWebView(
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.width(IntrinsicSize.Max)
|
modifier = Modifier
|
||||||
|
.width(IntrinsicSize.Max)
|
||||||
|
.heightIn(max = selectionMenuMaxHeight)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -1087,8 +1093,8 @@ fun ChapterWebView(
|
||||||
activeHighlightPalette.forEachIndexed { index, colorEnum ->
|
activeHighlightPalette.forEachIndexed { index, colorEnum ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 6.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.size(32.dp)
|
.size(28.dp)
|
||||||
.background(colorEnum.color, CircleShape)
|
.background(colorEnum.color, CircleShape)
|
||||||
.pointerInput(colorEnum) {
|
.pointerInput(colorEnum) {
|
||||||
detectTapGestures(onTap = {
|
detectTapGestures(onTap = {
|
||||||
|
|
@ -1115,7 +1121,7 @@ fun ChapterWebView(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
SpectrumButton(
|
SpectrumButton(
|
||||||
onClick = { showPaletteManager = true }, size = 32.dp
|
onClick = { showPaletteManager = true }, size = 28.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1123,7 +1129,7 @@ fun ChapterWebView(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ suspend fun summarizeBookContent(
|
||||||
onFinish: () -> Unit
|
onFinish: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (content.isBlank()) {
|
if (content.isBlank()) {
|
||||||
onError("The book content is empty.")
|
onError(context.getString(R.string.ai_error_book_content_empty))
|
||||||
onFinish()
|
onFinish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ suspend fun summarizeBookContent(
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions")
|
||||||
if (BuildConfig.FLAVOR == "oss") {
|
if (BuildConfig.FLAVOR == "oss") {
|
||||||
if (BuildConfig.IS_OFFLINE) {
|
if (BuildConfig.IS_OFFLINE) {
|
||||||
onError("AI features are unavailable in the offline OSS build.")
|
onError(context.getString(R.string.ai_error_offline_oss))
|
||||||
onFinish()
|
onFinish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,7 @@ suspend fun summarizeBookContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasReceivedData) {
|
if (!hasReceivedData) {
|
||||||
onError("Failed to parse summary from server response.")
|
onError(context.getString(R.string.ai_error_parse_summary))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try {
|
val errorBody = try {
|
||||||
|
|
@ -162,12 +162,12 @@ suspend fun summarizeBookContent(
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
val errorDetail = try {
|
val errorDetail = try {
|
||||||
JSONObject(errorBody.toString()).getString("detail")
|
JSONObject(errorBody.toString()).getString("detail")
|
||||||
} catch (_: Exception) { "Could not fetch summary." }
|
} catch (_: Exception) { context.getString(R.string.ai_error_fetch_summary) }
|
||||||
onError("Error: $responseCode. $errorDetail")
|
onError(context.getString(R.string.ai_error_with_code, responseCode, errorDetail))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Network error during summarization: ${e.message}")
|
Timber.e(e, "Network error during summarization: ${e.message}")
|
||||||
onError("Network error. Please check connection and server status.")
|
onError(context.getString(R.string.ai_error_network_server))
|
||||||
} finally {
|
} finally {
|
||||||
connection?.disconnect()
|
connection?.disconnect()
|
||||||
onFinish()
|
onFinish()
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,7 @@ fun PaginatedTextSelectionMenu(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
|
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
|
||||||
if (onHighlight != null) {
|
if (onHighlight != null) {
|
||||||
HighlightColorRow(
|
HighlightColorRow(
|
||||||
activeHighlightPalette = activeHighlightPalette,
|
activeHighlightPalette = activeHighlightPalette,
|
||||||
|
|
@ -531,7 +531,7 @@ fun PaginatedTextSelectionMenu(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -539,23 +539,23 @@ fun PaginatedTextSelectionMenu(
|
||||||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(64.dp)
|
.width(56.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { action.onClick() }
|
.clickable { action.onClick() }
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 6.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (action.imageVector != null) {
|
if (action.imageVector != null) {
|
||||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
} else if (action.iconRes != null) {
|
} else if (action.iconRes != null) {
|
||||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repeat(3 - rowActions.size) {
|
repeat(3 - rowActions.size) {
|
||||||
Spacer(modifier = Modifier.width(64.dp))
|
Spacer(modifier = Modifier.width(56.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -582,7 +582,7 @@ fun HighlightColorRow(
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -591,8 +591,8 @@ fun HighlightColorRow(
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 6.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.size(32.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape) // 1. Clip shape for ripple
|
.clip(CircleShape) // 1. Clip shape for ripple
|
||||||
.background(colorEnum.color) // 2. Apply background
|
.background(colorEnum.color) // 2. Apply background
|
||||||
.clickable {
|
.clickable {
|
||||||
|
|
@ -610,17 +610,17 @@ fun HighlightColorRow(
|
||||||
imageVector = Icons.Default.Check,
|
imageVector = Icons.Default.Check,
|
||||||
contentDescription = stringResource(R.string.content_desc_selected),
|
contentDescription = stringResource(R.string.content_desc_selected),
|
||||||
tint = if (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White,
|
tint = if (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onOpenPaletteManager != null) {
|
if (onOpenPaletteManager != null) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
SpectrumButton(
|
SpectrumButton(
|
||||||
onClick = onOpenPaletteManager,
|
onClick = onOpenPaletteManager,
|
||||||
size = 32.dp
|
size = 28.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -650,7 +650,7 @@ fun PaginatedTextSelectionMenu(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
|
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
|
||||||
// 1. Colors Row
|
// 1. Colors Row
|
||||||
if (onHighlight != null) {
|
if (onHighlight != null) {
|
||||||
HighlightColorRow(
|
HighlightColorRow(
|
||||||
|
|
@ -731,7 +731,7 @@ fun PaginatedTextSelectionMenu(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -739,22 +739,22 @@ fun PaginatedTextSelectionMenu(
|
||||||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(64.dp)
|
.width(56.dp)
|
||||||
.clickable { action.onClick() }
|
.clickable { action.onClick() }
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 6.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (action.imageVector != null) {
|
if (action.imageVector != null) {
|
||||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
} else if (action.iconRes != null) {
|
} else if (action.iconRes != null) {
|
||||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repeat(3 - rowActions.size) {
|
repeat(3 - rowActions.size) {
|
||||||
Spacer(modifier = Modifier.width(64.dp))
|
Spacer(modifier = Modifier.width(56.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import android.graphics.Canvas
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
|
@ -153,26 +154,26 @@ import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
enum class ReaderTool(val title: String, val category: String) {
|
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||||
DICTIONARY("External Apps", "Top Bar"),
|
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||||
THEME("Theme Settings", "Top Bar"),
|
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||||
TOC("Sidebar", "Bottom Bar"),
|
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||||
FORMAT("Text Formatting", "Bottom Bar"),
|
FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"),
|
||||||
SEARCH("Search", "Bottom Bar"),
|
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||||
AI_FEATURES("AI Features", "Bottom Bar"),
|
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
|
||||||
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
|
||||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
|
||||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
|
||||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||||
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
|
VOLUME_SCROLL(R.string.menu_volume_button_scrolling, "Overflow Menu"),
|
||||||
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
|
PAGE_TURN_ANIM(R.string.menu_realistic_page_turns, "Overflow Menu"),
|
||||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
|
||||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
||||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
||||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
||||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
|
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||||
|
|
@ -182,7 +183,8 @@ data class FlatToolItem(
|
||||||
val type: FlatItemType,
|
val type: FlatItemType,
|
||||||
val tool: ReaderTool? = null,
|
val tool: ReaderTool? = null,
|
||||||
val section: ToolbarSection? = null,
|
val section: ToolbarSection? = null,
|
||||||
val title: String? = null
|
val title: String? = null,
|
||||||
|
@StringRes val titleRes: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
||||||
|
|
@ -197,7 +199,7 @@ fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarSection.entries.forEach { section ->
|
ToolbarSection.entries.forEach { section ->
|
||||||
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
|
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||||
|
|
||||||
val tools = sectionMap[section] ?: emptyList()
|
val tools = sectionMap[section] ?: emptyList()
|
||||||
if (tools.isEmpty()) {
|
if (tools.isEmpty()) {
|
||||||
|
|
@ -266,6 +268,51 @@ private val epubToolbarTools = setOf(
|
||||||
ReaderTool.SCREEN_ORIENTATION
|
ReaderTool.SCREEN_ORIENTATION
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun defaultReaderHiddenTools(): Set<String> = setOf(ReaderTool.SCREEN_ORIENTATION.name)
|
||||||
|
|
||||||
|
internal fun defaultReaderToolOrder(): List<ReaderTool> = ReaderTool.entries.toList()
|
||||||
|
|
||||||
|
internal fun defaultReaderBottomTools(): Set<String> {
|
||||||
|
return ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildReaderToolbarItems(
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
toolOrder: List<ReaderTool>,
|
||||||
|
bottomTools: Set<String>
|
||||||
|
): List<FlatToolItem> {
|
||||||
|
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
|
||||||
|
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||||
|
val moreTools = toolOrder.filter { it !in epubToolbarTools }
|
||||||
|
|
||||||
|
val list = mutableListOf<FlatToolItem>()
|
||||||
|
|
||||||
|
ToolbarSection.entries.forEach { section ->
|
||||||
|
val tools = when (section) {
|
||||||
|
ToolbarSection.TOP -> topTools
|
||||||
|
ToolbarSection.BOTTOM -> bottomToolsList
|
||||||
|
ToolbarSection.HIDDEN -> hiddenToolsList
|
||||||
|
}
|
||||||
|
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||||
|
} else {
|
||||||
|
tools.forEach { tool ->
|
||||||
|
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
|
||||||
|
moreTools.forEach { tool ->
|
||||||
|
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpubReaderTopBar(
|
fun EpubReaderTopBar(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
|
|
@ -478,7 +525,7 @@ fun EpubReaderTopBar(
|
||||||
|
|
||||||
if (hiddenToolbarTools.isNotEmpty()) {
|
if (hiddenToolbarTools.isNotEmpty()) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Hidden tools") },
|
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -1717,37 +1764,11 @@ fun CustomizeToolsSheet(
|
||||||
|
|
||||||
var flatItems by remember {
|
var flatItems by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
run {
|
buildReaderToolbarItems(
|
||||||
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
|
hiddenTools = hiddenTools,
|
||||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
toolOrder = toolOrder,
|
||||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
bottomTools = bottomTools
|
||||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
)
|
||||||
val moreTools = toolOrder.filter { it !in epubToolbarTools }
|
|
||||||
|
|
||||||
val list = mutableListOf<FlatToolItem>()
|
|
||||||
|
|
||||||
ToolbarSection.entries.forEach { section ->
|
|
||||||
val tools = when(section) {
|
|
||||||
ToolbarSection.TOP -> topTools
|
|
||||||
ToolbarSection.BOTTOM -> bottomToolsList
|
|
||||||
ToolbarSection.HIDDEN -> hiddenToolsList
|
|
||||||
}
|
|
||||||
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
|
|
||||||
if (tools.isEmpty()) {
|
|
||||||
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
|
|
||||||
} else {
|
|
||||||
tools.forEach { tool ->
|
|
||||||
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu"))
|
|
||||||
moreTools.forEach { tool ->
|
|
||||||
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
|
|
||||||
}
|
|
||||||
list
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1812,6 +1833,22 @@ fun CustomizeToolsSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val resetToDefault = {
|
||||||
|
val defaultHiddenTools = defaultReaderHiddenTools()
|
||||||
|
val defaultToolOrder = defaultReaderToolOrder()
|
||||||
|
val defaultBottomTools = defaultReaderBottomTools()
|
||||||
|
|
||||||
|
localHiddenTools = defaultHiddenTools
|
||||||
|
flatItems = buildReaderToolbarItems(
|
||||||
|
hiddenTools = defaultHiddenTools,
|
||||||
|
toolOrder = defaultToolOrder,
|
||||||
|
bottomTools = defaultBottomTools
|
||||||
|
)
|
||||||
|
onUpdate(defaultHiddenTools)
|
||||||
|
onPlacementUpdate(defaultBottomTools)
|
||||||
|
onOrderUpdate(defaultToolOrder)
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
|
@ -1828,12 +1865,17 @@ fun CustomizeToolsSheet(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Customize Toolbar",
|
text = stringResource(R.string.title_customize_toolbar),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
TextButton(onClick = resetToDefault) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.action_reset))
|
||||||
|
}
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
}
|
}
|
||||||
|
|
@ -1866,8 +1908,9 @@ fun CustomizeToolsSheet(
|
||||||
) {
|
) {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
FlatItemType.SECTION_HEADER -> {
|
FlatItemType.SECTION_HEADER -> {
|
||||||
|
val titleRes = item.titleRes
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: "",
|
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -1883,7 +1926,7 @@ fun CustomizeToolsSheet(
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FlatItemType.TOOL -> {
|
FlatItemType.TOOL -> {
|
||||||
|
|
@ -1900,8 +1943,9 @@ fun CustomizeToolsSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FlatItemType.MORE_HEADER -> {
|
FlatItemType.MORE_HEADER -> {
|
||||||
|
val titleRes = item.titleRes
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: "More menu",
|
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
||||||
|
|
@ -1909,7 +1953,7 @@ fun CustomizeToolsSheet(
|
||||||
}
|
}
|
||||||
FlatItemType.MORE_TOOL -> {
|
FlatItemType.MORE_TOOL -> {
|
||||||
MoreToolVisibilityRow(
|
MoreToolVisibilityRow(
|
||||||
title = item.tool!!.title,
|
title = stringResource(item.tool!!.titleRes),
|
||||||
visible = !localHiddenTools.contains(item.tool.name),
|
visible = !localHiddenTools.contains(item.tool.name),
|
||||||
onToggle = {
|
onToggle = {
|
||||||
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
||||||
|
|
@ -1952,14 +1996,14 @@ private fun ToolbarDragRow(
|
||||||
ToolPreviewIcon(tool)
|
ToolPreviewIcon(tool)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = tool.title,
|
text = stringResource(tool.titleRes),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Menu,
|
Icons.Default.Menu,
|
||||||
contentDescription = "Drag to reorder",
|
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
|
|
@ -2007,25 +2051,26 @@ private fun MoreToolVisibilityRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ToolbarSection(val title: String) {
|
enum class ToolbarSection(@StringRes val titleRes: Int) {
|
||||||
TOP("Top Bar"),
|
TOP(R.string.toolbar_top_bar),
|
||||||
BOTTOM("Bottom Bar"),
|
BOTTOM(R.string.toolbar_bottom_bar),
|
||||||
HIDDEN("Hidden Tools")
|
HIDDEN(R.string.toolbar_hidden_tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToolPreviewIcon(tool: ReaderTool) {
|
private fun ToolPreviewIcon(tool: ReaderTool) {
|
||||||
|
val title = stringResource(tool.titleRes)
|
||||||
when (tool) {
|
when (tool) {
|
||||||
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2050,7 +2095,7 @@ private fun HiddenEpubToolMenuItem(
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(tool.title) },
|
text = { Text(stringResource(tool.titleRes)) },
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
showMoreMenu()
|
showMoreMenu()
|
||||||
|
|
@ -2198,7 +2243,11 @@ fun TtsOverlayControls(
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device",
|
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||||
|
stringResource(R.string.tts_mode_cloud_ai)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.tts_mode_device_native)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
|
@ -2211,7 +2260,7 @@ fun TtsOverlayControls(
|
||||||
) {
|
) {
|
||||||
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||||
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
|
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
|
||||||
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
|
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: stringResource(R.string.label_default)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
voiceName,
|
voiceName,
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,15 @@ private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||||
|
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||||
|
|
||||||
|
private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String {
|
||||||
|
return text
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', ' ')
|
||||||
|
.replace('\t', ' ')
|
||||||
|
.take(maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
private fun View.bottomRoundedCornerRadiusPx(): Int {
|
private fun View.bottomRoundedCornerRadiusPx(): Int {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
|
||||||
|
|
@ -303,7 +312,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
||||||
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||||
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||||
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||||
val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name
|
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||||
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
|
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
|
|
@ -325,7 +334,7 @@ private fun loadToolOrder(context: Context): List<ReaderTool> {
|
||||||
?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
|
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
|
return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
|
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
|
||||||
|
|
@ -337,8 +346,8 @@ private fun loadBottomTools(context: Context): Set<String> {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
return prefs.getStringSet(
|
return prefs.getStringSet(
|
||||||
BOTTOM_TOOLS_KEY,
|
BOTTOM_TOOLS_KEY,
|
||||||
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
defaultReaderBottomTools()
|
||||||
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
) ?: defaultReaderBottomTools()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
||||||
|
|
@ -2413,13 +2422,13 @@ fun EpubReaderHost(
|
||||||
val targetPageIndex = pageIndex
|
val targetPageIndex = pageIndex
|
||||||
val targetCfi = cfi.orEmpty()
|
val targetCfi = cfi.orEmpty()
|
||||||
if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) {
|
if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) {
|
||||||
return "Page ${targetPageIndex + 1}"
|
return context.getString(R.string.pdf_page_short, targetPageIndex + 1)
|
||||||
}
|
}
|
||||||
val chapter = chapterIndex
|
val chapter = chapterIndex
|
||||||
return if (chapter != null) {
|
return if (chapter != null) {
|
||||||
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}"
|
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chapter_number_format, chapter + 1)
|
||||||
} else {
|
} else {
|
||||||
"Location"
|
context.getString(R.string.location_generic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3262,7 +3271,7 @@ fun EpubReaderHost(
|
||||||
} ?: run {
|
} ?: run {
|
||||||
isSummarizationLoading = false
|
isSummarizationLoading = false
|
||||||
summarizationResult =
|
summarizationResult =
|
||||||
SummarizationResult(error = "WebView not available.")
|
SummarizationResult(error = context.getString(R.string.error_webview_not_available))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3333,7 +3342,7 @@ fun EpubReaderHost(
|
||||||
if (fullSummary.isNotBlank()) {
|
if (fullSummary.isNotBlank()) {
|
||||||
val chapterTitle =
|
val chapterTitle =
|
||||||
chapters.getOrNull(chapterIndex)?.title
|
chapters.getOrNull(chapterIndex)?.title
|
||||||
?: "Chapter ${chapterIndex + 1}"
|
?: context.getString(R.string.chapter_number_format, chapterIndex + 1)
|
||||||
summaryCacheManager.saveSummary(
|
summaryCacheManager.saveSummary(
|
||||||
epubBook.title,
|
epubBook.title,
|
||||||
chapterIndex,
|
chapterIndex,
|
||||||
|
|
@ -3344,12 +3353,12 @@ fun EpubReaderHost(
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
summarizationResult =
|
summarizationResult =
|
||||||
SummarizationResult(error = "Could not get chapter content.")
|
SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content))
|
||||||
isSummarizationLoading = false
|
isSummarizationLoading = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
summarizationResult =
|
summarizationResult =
|
||||||
SummarizationResult(error = "Could not determine current chapter.")
|
SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter))
|
||||||
isSummarizationLoading = false
|
isSummarizationLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4207,7 +4216,7 @@ fun EpubReaderHost(
|
||||||
isSummarizationLoading = false
|
isSummarizationLoading = false
|
||||||
val fullSummary = finalSummaryBuilder.toString()
|
val fullSummary = finalSummaryBuilder.toString()
|
||||||
if (fullSummary.isNotBlank()) {
|
if (fullSummary.isNotBlank()) {
|
||||||
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}"
|
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: context.getString(R.string.chapter_number_format, chapterIndexToSave + 1)
|
||||||
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
|
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4362,7 +4371,7 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
val chapterTitle =
|
val chapterTitle =
|
||||||
epubBook.chapters.getOrNull(currentChapterIndex)?.title
|
epubBook.chapters.getOrNull(currentChapterIndex)?.title
|
||||||
?: "Unknown Chapter"
|
?: context.getString(R.string.unknown_chapter)
|
||||||
val newBookmark = Bookmark(
|
val newBookmark = Bookmark(
|
||||||
cfi = cfi,
|
cfi = cfi,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
|
|
@ -4571,15 +4580,33 @@ fun EpubReaderHost(
|
||||||
highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1)
|
highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1)
|
||||||
},
|
},
|
||||||
onHighlightCreated = { cfi, text, colorId ->
|
onHighlightCreated = { cfi, text, colorId ->
|
||||||
|
val chapterIndex = currentChapterInPaginatedMode ?: 0
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " +
|
||||||
|
"existingCount=${userHighlights.size} textLen=${text.length} " +
|
||||||
|
"text='${epubHighlightDiagSnippet(text)}'"
|
||||||
|
)
|
||||||
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
|
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
|
||||||
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
|
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
|
||||||
val finalCfi = processAndAddHighlight(
|
val finalCfi = processAndAddHighlight(
|
||||||
newCfi = cfi,
|
newCfi = cfi,
|
||||||
newText = text,
|
newText = text,
|
||||||
newColor = color,
|
newColor = color,
|
||||||
chapterIndex = currentChapterInPaginatedMode ?: 0,
|
chapterIndex = chapterIndex,
|
||||||
currentList = userHighlights
|
currentList = userHighlights
|
||||||
)
|
)
|
||||||
|
val savedHighlight = userHighlights.find {
|
||||||
|
it.chapterIndex == chapterIndex && it.cfi == finalCfi
|
||||||
|
}
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"persist_result finalCfi=$finalCfi chapter=$chapterIndex " +
|
||||||
|
"savedId=${savedHighlight?.id} totalCount=${userHighlights.size} " +
|
||||||
|
"matchingCfiCount=${userHighlights.count { it.chapterIndex == chapterIndex && it.cfi == finalCfi }} " +
|
||||||
|
"locatorStart=${savedHighlight?.locator?.startOffset} " +
|
||||||
|
"locatorEnd=${savedHighlight?.locator?.endOffset} " +
|
||||||
|
"locatorPage=${savedHighlight?.locator?.pageIndex} " +
|
||||||
|
"locatorCfi=${savedHighlight?.locator?.cfi}"
|
||||||
|
)
|
||||||
if (pendingNoteForNewHighlight) {
|
if (pendingNoteForNewHighlight) {
|
||||||
pendingNoteForNewHighlight = false
|
pendingNoteForNewHighlight = false
|
||||||
highlightToNoteCfi = finalCfi
|
highlightToNoteCfi = finalCfi
|
||||||
|
|
@ -4616,9 +4643,24 @@ fun EpubReaderHost(
|
||||||
)?.let { recordEpubJump(it) }
|
)?.let { recordEpubJump(it) }
|
||||||
},
|
},
|
||||||
onHighlightDeleted = { cfi ->
|
onHighlightDeleted = { cfi ->
|
||||||
|
val beforeCount = userHighlights.size
|
||||||
val toRemove = userHighlights.find { it.cfi == cfi }
|
val toRemove = userHighlights.find { it.cfi == cfi }
|
||||||
if (toRemove != null) {
|
if (toRemove != null) {
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"delete_request cfi=$cfi matchedId=${toRemove.id} " +
|
||||||
|
"matchedChapter=${toRemove.chapterIndex} beforeCount=$beforeCount " +
|
||||||
|
"locatorStart=${toRemove.locator.startOffset} " +
|
||||||
|
"locatorEnd=${toRemove.locator.endOffset}"
|
||||||
|
)
|
||||||
userHighlights.remove(toRemove)
|
userHighlights.remove(toRemove)
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"delete_result cfi=$cfi removedId=${toRemove.id} " +
|
||||||
|
"afterCount=${userHighlights.size}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w(
|
||||||
|
"delete_request cfi=$cfi matchedId=null beforeCount=$beforeCount"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -4745,7 +4787,7 @@ fun EpubReaderHost(
|
||||||
val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi
|
val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi
|
||||||
|
|
||||||
val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
||||||
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: "Unknown Chapter"
|
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: context.getString(R.string.unknown_chapter)
|
||||||
val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: ""
|
val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: ""
|
||||||
|
|
||||||
val pageInChapter: Int?
|
val pageInChapter: Int?
|
||||||
|
|
@ -4862,7 +4904,7 @@ fun EpubReaderHost(
|
||||||
val textToShow = if (bookPaginator != null && chapterIndex != null) {
|
val textToShow = if (bookPaginator != null && chapterIndex != null) {
|
||||||
val chapterTitle =
|
val chapterTitle =
|
||||||
chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim()
|
chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim()
|
||||||
?: "Chapter"
|
?: stringResource(R.string.chapter)
|
||||||
val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex]
|
val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex]
|
||||||
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex]
|
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex]
|
||||||
|
|
||||||
|
|
@ -4874,7 +4916,7 @@ fun EpubReaderHost(
|
||||||
chapterTitle
|
chapterTitle
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"Page ${paginatedPagerState.currentPage + 1}/${paginatedPagerState.pageCount}"
|
stringResource(R.string.page_number_of_total, paginatedPagerState.currentPage + 1, paginatedPagerState.pageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -5568,7 +5610,7 @@ fun EpubReaderHost(
|
||||||
onVerticalMarginChange = { currentVerticalMargin = it },
|
onVerticalMarginChange = { currentVerticalMargin = it },
|
||||||
currentFont = currentFontFamily,
|
currentFont = currentFontFamily,
|
||||||
currentCustomFontName = if(currentCustomFontPath != null) {
|
currentCustomFontName = if(currentCustomFontPath != null) {
|
||||||
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
|
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: stringResource(R.string.custom_font_fallback)
|
||||||
} else null,
|
} else null,
|
||||||
onFontOptionClick = { showFontSelectionSheet = true },
|
onFontOptionClick = { showFontSelectionSheet = true },
|
||||||
currentTextAlign = currentTextAlign,
|
currentTextAlign = currentTextAlign,
|
||||||
|
|
@ -5649,7 +5691,7 @@ fun EpubReaderHost(
|
||||||
credits = credits,
|
credits = credits,
|
||||||
isProUser = isProUser,
|
isProUser = isProUser,
|
||||||
currentChapterIndex = effectiveCurrentChapterIndex,
|
currentChapterIndex = effectiveCurrentChapterIndex,
|
||||||
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}",
|
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: context.getString(R.string.chapter_number_format, effectiveCurrentChapterIndex + 1),
|
||||||
showAiHubSheet = showAiHubSheet,
|
showAiHubSheet = showAiHubSheet,
|
||||||
onGenerateSummary = handleGenerateSummary,
|
onGenerateSummary = handleGenerateSummary,
|
||||||
onGenerateRecap = handleGenerateRecap,
|
onGenerateRecap = handleGenerateRecap,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
|
@ -161,27 +162,28 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam
|
||||||
LEXEND("lexend", "Lexend", "Lexend")
|
LEXEND("lexend", "Lexend", "Lexend")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) {
|
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) {
|
||||||
DEFAULT("default", "", R.drawable.format_align_left, "Default"),
|
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default),
|
||||||
LEFT("left", "left", R.drawable.format_align_left, "Left"),
|
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left),
|
||||||
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
|
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right),
|
||||||
|
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class SystemUiMode(val id: Int, val title: String) {
|
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) {
|
||||||
DEFAULT(0, "Always Show"),
|
DEFAULT(0, R.string.label_always_show),
|
||||||
SYNC(1, "Sync with Menus"),
|
SYNC(1, R.string.label_sync_with_menus),
|
||||||
HIDDEN(2, "Always Hide")
|
HIDDEN(2, R.string.label_always_hide)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class PageInfoMode(val id: Int, val title: String) {
|
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) {
|
||||||
DEFAULT(0, "Always Show"),
|
DEFAULT(0, R.string.label_always_show),
|
||||||
SYNC(1, "Sync with Menus"),
|
SYNC(1, R.string.label_sync_with_menus),
|
||||||
HIDDEN(2, "Always Hide")
|
HIDDEN(2, R.string.label_always_hide)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) {
|
||||||
BOTTOM(0, "Bottom"),
|
BOTTOM(0, R.string.label_bottom),
|
||||||
TOP(1, "Top")
|
TOP(1, R.string.label_top)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FormatSettings(
|
data class FormatSettings(
|
||||||
|
|
@ -649,6 +651,7 @@ fun ReaderTextFormatPanel(
|
||||||
Row {
|
Row {
|
||||||
ReaderTextAlign.entries.forEach { align ->
|
ReaderTextAlign.entries.forEach { align ->
|
||||||
val isSelected = currentTextAlign == align
|
val isSelected = currentTextAlign == align
|
||||||
|
val alignDisplayName = stringResource(align.displayNameRes)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
|
@ -661,12 +664,12 @@ fun ReaderTextFormatPanel(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = align.iconResId),
|
painter = painterResource(id = align.iconResId),
|
||||||
contentDescription = align.displayName,
|
contentDescription = alignDisplayName,
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = align.displayName,
|
text = alignDisplayName,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
@ -923,7 +926,7 @@ fun VisualOptionsSheet(
|
||||||
options = SystemUiMode.entries,
|
options = SystemUiMode.entries,
|
||||||
selectedOption = systemUiMode,
|
selectedOption = systemUiMode,
|
||||||
onOptionSelected = onSystemUiModeChange,
|
onOptionSelected = onSystemUiModeChange,
|
||||||
getLabel = { it.title }
|
getLabel = { stringResource(it.titleRes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
@ -936,7 +939,7 @@ fun VisualOptionsSheet(
|
||||||
options = PageInfoMode.entries,
|
options = PageInfoMode.entries,
|
||||||
selectedOption = pageInfoMode,
|
selectedOption = pageInfoMode,
|
||||||
onOptionSelected = onPageInfoModeChange,
|
onOptionSelected = onPageInfoModeChange,
|
||||||
getLabel = { it.title }
|
getLabel = { stringResource(it.titleRes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
@ -946,7 +949,7 @@ fun VisualOptionsSheet(
|
||||||
options = PageInfoPosition.entries,
|
options = PageInfoPosition.entries,
|
||||||
selectedOption = pageInfoPosition,
|
selectedOption = pageInfoPosition,
|
||||||
onOptionSelected = onPageInfoPositionChange,
|
onOptionSelected = onPageInfoPositionChange,
|
||||||
getLabel = { it.title }
|
getLabel = { stringResource(it.titleRes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
@ -1003,7 +1006,7 @@ fun <T> OptionSegmentedControl(
|
||||||
options: List<T>,
|
options: List<T>,
|
||||||
selectedOption: T,
|
selectedOption: T,
|
||||||
onOptionSelected: (T) -> Unit,
|
onOptionSelected: (T) -> Unit,
|
||||||
getLabel: (T) -> String
|
getLabel: @Composable (T) -> String
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.PopupMenu
|
import android.widget.PopupMenu
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
||||||
|
|
@ -52,6 +53,9 @@ class InteractiveWebView(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DRAG_SENSITIVITY_PX = 20f
|
private const val DRAG_SENSITIVITY_PX = 20f
|
||||||
|
private const val SELECTION_MENU_INITIAL_DELAY_MS = 120L
|
||||||
|
private const val SELECTION_MENU_RETRY_DELAY_MS = 140L
|
||||||
|
private const val SELECTION_MENU_RETRY_COUNT = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
private var startY: Float = 0f
|
private var startY: Float = 0f
|
||||||
|
|
@ -60,16 +64,26 @@ class InteractiveWebView(
|
||||||
|
|
||||||
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
private val scrollStopHandler = Handler(Looper.getMainLooper())
|
||||||
private var scrollStopRunnable: Runnable? = null
|
private var scrollStopRunnable: Runnable? = null
|
||||||
|
private var selectionMenuRunnable: Runnable? = null
|
||||||
private var activeSelectionActionMode: ActionMode? = null
|
private var activeSelectionActionMode: ActionMode? = null
|
||||||
|
private var selectionMenuShownForActiveMode = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
addJavascriptInterface(ReaderSelectionBridge(), "ReaderSelectionBridge")
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearPendingSelectionWork() {
|
private fun clearPendingSelectionWork() {
|
||||||
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
scrollStopRunnable = null
|
scrollStopRunnable = null
|
||||||
|
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
|
selectionMenuRunnable = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLocalSelectionActionMode(): ActionMode {
|
private fun startLocalSelectionActionMode(scheduleMenu: Boolean = true): ActionMode {
|
||||||
activeSelectionActionMode?.let { existingMode ->
|
activeSelectionActionMode?.let { existingMode ->
|
||||||
showCustomSelectionMenuFromCurrentSelection(existingMode)
|
if (scheduleMenu) {
|
||||||
|
scheduleCustomSelectionMenuFromCurrentSelection(existingMode)
|
||||||
|
}
|
||||||
return existingMode
|
return existingMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,10 +92,14 @@ class InteractiveWebView(
|
||||||
if (activeSelectionActionMode === localMode) {
|
if (activeSelectionActionMode === localMode) {
|
||||||
activeSelectionActionMode = null
|
activeSelectionActionMode = null
|
||||||
}
|
}
|
||||||
|
selectionMenuShownForActiveMode = false
|
||||||
onHideCustomSelectionMenu()
|
onHideCustomSelectionMenu()
|
||||||
}
|
}
|
||||||
|
selectionMenuShownForActiveMode = false
|
||||||
activeSelectionActionMode = localMode
|
activeSelectionActionMode = localMode
|
||||||
showCustomSelectionMenuFromCurrentSelection(localMode)
|
if (scheduleMenu) {
|
||||||
|
scheduleCustomSelectionMenuFromCurrentSelection(localMode)
|
||||||
|
}
|
||||||
return localMode
|
return localMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +108,56 @@ class InteractiveWebView(
|
||||||
activeSelectionActionMode = null
|
activeSelectionActionMode = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
|
private fun scheduleCustomSelectionMenuFromCurrentSelection(
|
||||||
|
mode: ActionMode,
|
||||||
|
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
|
||||||
|
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
|
||||||
|
) {
|
||||||
|
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
|
selectionMenuRunnable = Runnable {
|
||||||
|
selectionMenuRunnable = null
|
||||||
|
showCustomSelectionMenuFromCurrentSelection(mode, remainingRetries)
|
||||||
|
}
|
||||||
|
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSelectionActionModeRefreshAfterTouch(
|
||||||
|
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
|
||||||
|
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
|
||||||
|
) {
|
||||||
|
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
|
selectionMenuRunnable = Runnable {
|
||||||
|
selectionMenuRunnable = null
|
||||||
|
activeSelectionActionMode?.let { mode ->
|
||||||
|
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
evaluateJavascript("(function() { var s = window.getSelection && window.getSelection(); return s ? s.toString().trim() : ''; })();") { result ->
|
||||||
|
val selectedText = result?.removeSurrounding("\"")
|
||||||
|
if (!selectedText.isNullOrBlank() && activeSelectionActionMode == null) {
|
||||||
|
Timber.d("CustomSelection: selection exists after touch-up. Starting local action mode.")
|
||||||
|
startLocalSelectionActionMode()
|
||||||
|
} else {
|
||||||
|
activeSelectionActionMode?.let { mode ->
|
||||||
|
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
if (remainingRetries > 0) {
|
||||||
|
scheduleSelectionActionModeRefreshAfterTouch(
|
||||||
|
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||||
|
remainingRetries = remainingRetries - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCustomSelectionMenuFromCurrentSelection(
|
||||||
|
mode: ActionMode,
|
||||||
|
remainingRetries: Int
|
||||||
|
) {
|
||||||
val jsToGetSelectionDetails = """
|
val jsToGetSelectionDetails = """
|
||||||
(function() {
|
(function() {
|
||||||
var selection = window.getSelection();
|
var selection = window.getSelection();
|
||||||
|
|
@ -99,20 +166,44 @@ class InteractiveWebView(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var range = selection.getRangeAt(0);
|
var range = selection.getRangeAt(0);
|
||||||
var rect = range.getBoundingClientRect();
|
var viewportLeft = 0;
|
||||||
|
var viewportTop = 0;
|
||||||
// If getBoundingClientRect returns all zeros, try getClientRects()
|
var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||||
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
|
var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
var clientRects = range.getClientRects();
|
var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []);
|
||||||
if (clientRects.length > 0) {
|
rects = rects.filter(function(rect) {
|
||||||
rect = clientRects[0]; // Use the first rect
|
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||||
|
return rect.right >= viewportLeft &&
|
||||||
|
rect.left <= viewportRight &&
|
||||||
|
rect.bottom >= viewportTop &&
|
||||||
|
rect.top <= viewportBottom;
|
||||||
|
});
|
||||||
|
var rect = null;
|
||||||
|
if (rects.length > 0) {
|
||||||
|
var firstRect = rects[0];
|
||||||
|
rect = rects.reduce(function(acc, item) {
|
||||||
|
return {
|
||||||
|
left: Math.min(acc.left, item.left),
|
||||||
|
top: Math.min(acc.top, item.top),
|
||||||
|
right: Math.max(acc.right, item.right),
|
||||||
|
bottom: Math.max(acc.bottom, item.bottom)
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
left: firstRect.left,
|
||||||
|
top: firstRect.top,
|
||||||
|
right: firstRect.right,
|
||||||
|
bottom: firstRect.bottom
|
||||||
|
});
|
||||||
|
rect.width = rect.right - rect.left;
|
||||||
|
rect.height = rect.bottom - rect.top;
|
||||||
} else {
|
} else {
|
||||||
return null; // No valid rect found
|
rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null;
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the rect has some dimension
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
if (rect.width === 0 && rect.height === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,13 +220,28 @@ class InteractiveWebView(
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
|
||||||
|
fun retryOrFinish(message: String) {
|
||||||
|
Timber.d(message)
|
||||||
|
if (selectionMenuShownForActiveMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeSelectionActionMode === mode && remainingRetries > 0) {
|
||||||
|
scheduleCustomSelectionMenuFromCurrentSelection(
|
||||||
|
mode = mode,
|
||||||
|
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||||
|
remainingRetries = remainingRetries - 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mode.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (activeSelectionActionMode !== mode) {
|
if (activeSelectionActionMode !== mode) {
|
||||||
return@evaluateJavascript
|
return@evaluateJavascript
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
|
||||||
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
|
retryOrFinish("CustomSelection: JS returned null or invalid for selection details. Retries left: $remainingRetries")
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
return@evaluateJavascript
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,8 +254,7 @@ class InteractiveWebView(
|
||||||
val selectedText = selectionDetails.getString("text")
|
val selectedText = selectionDetails.getString("text")
|
||||||
|
|
||||||
if (selectedText.isBlank()) {
|
if (selectedText.isBlank()) {
|
||||||
Timber.d("CustomSelection: Selected text is blank after JS processing.")
|
retryOrFinish("CustomSelection: Selected text is blank after JS processing. Retries left: $remainingRetries")
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
return@evaluateJavascript
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,8 +266,7 @@ class InteractiveWebView(
|
||||||
val jsHeight = selectionDetails.getDouble("height")
|
val jsHeight = selectionDetails.getDouble("height")
|
||||||
|
|
||||||
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
if (jsWidth == 0.0 && jsHeight == 0.0) {
|
||||||
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
|
retryOrFinish("CustomSelection: JS returned a zero-area rect. Retries left: $remainingRetries. Left: $jsLeft, Top: $jsTop")
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
return@evaluateJavascript
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,22 +285,87 @@ class InteractiveWebView(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||||
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
retryOrFinish("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. Retries left: $remainingRetries. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
|
||||||
mode.finish()
|
|
||||||
return@evaluateJavascript
|
return@evaluateJavascript
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
|
||||||
|
|
||||||
|
selectionMenuShownForActiveMode = true
|
||||||
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||||
mode.finish()
|
mode.finish()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
|
||||||
|
if (selectionMenuShownForActiveMode) {
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
if (remainingRetries > 0) {
|
||||||
|
scheduleCustomSelectionMenuFromCurrentSelection(
|
||||||
|
mode = mode,
|
||||||
|
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
|
||||||
|
remainingRetries = remainingRetries - 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
mode.finish()
|
mode.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCustomSelectionMenuFromSelectionDetailsJson(
|
||||||
|
mode: ActionMode,
|
||||||
|
rawJson: String
|
||||||
|
) {
|
||||||
|
if (activeSelectionActionMode !== mode) return
|
||||||
|
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
|
||||||
|
selectionMenuRunnable = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val selectionDetails = JSONObject(rawJson)
|
||||||
|
val selectedText = selectionDetails.getString("text")
|
||||||
|
if (selectedText.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsLeft = selectionDetails.getDouble("left")
|
||||||
|
val jsTop = selectionDetails.getDouble("top")
|
||||||
|
val jsRight = selectionDetails.getDouble("right")
|
||||||
|
val jsBottom = selectionDetails.getDouble("bottom")
|
||||||
|
val jsWidth = selectionDetails.getDouble("width")
|
||||||
|
val jsHeight = selectionDetails.getDouble("height")
|
||||||
|
|
||||||
|
if (jsWidth <= 0.0 || jsHeight <= 0.0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = context.resources.displayMetrics.density
|
||||||
|
val webViewLocation = IntArray(2)
|
||||||
|
getLocationOnScreen(webViewLocation)
|
||||||
|
val webViewX = webViewLocation[0]
|
||||||
|
val webViewY = webViewLocation[1]
|
||||||
|
|
||||||
|
val selectionRectScreen = Rect(
|
||||||
|
(webViewX + jsLeft * density).toInt(),
|
||||||
|
(webViewY + jsTop * density).toInt(),
|
||||||
|
(webViewX + jsRight * density).toInt(),
|
||||||
|
(webViewY + jsBottom * density).toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
|
||||||
|
Timber.d("CustomSelection: Bridge supplied invalid rect: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("CustomSelection: Bridge selected text '$selectedText', Screen Rect: $selectionRectScreen")
|
||||||
|
selectionMenuShownForActiveMode = true
|
||||||
|
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
|
||||||
|
mode.finish()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "CustomSelection: Error parsing bridge selection details: '$rawJson'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val gestureDetector =
|
private val gestureDetector =
|
||||||
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
|
|
@ -292,6 +461,8 @@ class InteractiveWebView(
|
||||||
if (wasDragging) {
|
if (wasDragging) {
|
||||||
Timber.d("Drag operation ended, enabling text selection.")
|
Timber.d("Drag operation ended, enabling text selection.")
|
||||||
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
|
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
|
||||||
|
} else if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||||
|
scheduleSelectionActionModeRefreshAfterTouch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -306,12 +477,14 @@ class InteractiveWebView(
|
||||||
|
|
||||||
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
|
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
|
||||||
// selections use the app's Compose popup without starting the platform toolbar.
|
// selections use the app's Compose popup without starting the platform toolbar.
|
||||||
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
override fun startActionMode(originalCallback: ActionMode.Callback): ActionMode? {
|
||||||
if (type == ActionMode.TYPE_FLOATING) {
|
Timber.d("CustomSelection: handling primary action mode locally.")
|
||||||
Timber.d("CustomSelection: handling floating action mode locally.")
|
|
||||||
return startLocalSelectionActionMode()
|
return startLocalSelectionActionMode()
|
||||||
}
|
}
|
||||||
return super.startActionMode(originalCallback, type)
|
|
||||||
|
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
|
||||||
|
Timber.d("CustomSelection: handling action mode locally. Type: $type")
|
||||||
|
return startLocalSelectionActionMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||||
|
|
@ -392,4 +565,14 @@ class InteractiveWebView(
|
||||||
|
|
||||||
override fun getMenuInflater(): MenuInflater = menuInflater
|
override fun getMenuInflater(): MenuInflater = menuInflater
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class ReaderSelectionBridge {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onSelectionChanged(selectionJson: String) {
|
||||||
|
post {
|
||||||
|
val mode = startLocalSelectionActionMode(scheduleMenu = false)
|
||||||
|
showCustomSelectionMenuFromSelectionDetailsJson(mode, selectionJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,12 @@ package com.aryan.reader.feedback
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.aryan.reader.AuthRepository
|
import com.aryan.reader.AuthRepository
|
||||||
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.data.FeedbackMessage
|
import com.aryan.reader.data.FeedbackMessage
|
||||||
import com.aryan.reader.data.FeedbackRepository
|
import com.aryan.reader.data.FeedbackRepository
|
||||||
import com.aryan.reader.data.FeedbackThread
|
import com.aryan.reader.data.FeedbackThread
|
||||||
|
|
@ -61,11 +63,15 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
private var messagesListener: Any? = null
|
private var messagesListener: Any? = null
|
||||||
private val currentUser = authRepository.getSignedInUser()
|
private val currentUser = authRepository.getSignedInUser()
|
||||||
|
|
||||||
|
private fun string(@StringRes resId: Int, vararg args: Any?): String {
|
||||||
|
return getApplication<Application>().getString(resId, *args)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
startListeningToThreads(currentUser.uid)
|
startListeningToThreads(currentUser.uid)
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_required)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +133,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
fun onStartCreateTicket() {
|
fun onStartCreateTicket() {
|
||||||
if (currentUser == null) {
|
if (currentUser == null) {
|
||||||
_uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_sign_in_submit)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isCreatingTicket = true) }
|
_uiState.update { it.copy(isCreatingTicket = true) }
|
||||||
|
|
@ -148,7 +154,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
fun onNewTicketImagesSelected(uris: List<Uri>) {
|
fun onNewTicketImagesSelected(uris: List<Uri>) {
|
||||||
val current = _uiState.value.newTicketAttachments
|
val current = _uiState.value.newTicketAttachments
|
||||||
if (current.size + uris.size > 3) {
|
if (current.size + uris.size > 3) {
|
||||||
_uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_ticket_image_limit)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
validateAndAddImages(uris) { validUris ->
|
validateAndAddImages(uris) { validUris ->
|
||||||
|
|
@ -167,7 +173,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
fun onChatImagesSelected(uris: List<Uri>) {
|
fun onChatImagesSelected(uris: List<Uri>) {
|
||||||
val current = _uiState.value.chatInputAttachments
|
val current = _uiState.value.chatInputAttachments
|
||||||
if (current.size + uris.size > 5) {
|
if (current.size + uris.size > 5) {
|
||||||
_uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_message_image_limit)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
validateAndAddImages(uris) { validUris ->
|
validateAndAddImages(uris) { validUris ->
|
||||||
|
|
@ -186,7 +192,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
for (uri in uris) {
|
for (uri in uris) {
|
||||||
val fileSize = getFileSize(context, uri)
|
val fileSize = getFileSize(context, uri)
|
||||||
if (fileSize > maxFileSize) {
|
if (fileSize > maxFileSize) {
|
||||||
_uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_images_size_limit)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
validUris.add(uri)
|
validUris.add(uri)
|
||||||
|
|
@ -219,7 +225,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
onThreadSelected(threadId)
|
onThreadSelected(threadId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "ViewModel: Error submitting ticket")
|
Timber.e(e, "ViewModel: Error submitting ticket")
|
||||||
_uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") }
|
_uiState.update { it.copy(errorMessage = string(R.string.feedback_error_create_ticket, e.message.orEmpty())) }
|
||||||
} finally {
|
} finally {
|
||||||
_uiState.update { it.copy(isLoading = false) }
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +277,7 @@ class FeedbackViewModel(application: Application) : AndroidViewModel(application
|
||||||
Timber.e(e, "ViewModel: Error sending message")
|
Timber.e(e, "ViewModel: Error sending message")
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
errorMessage = "Failed to send: ${e.message}",
|
errorMessage = string(R.string.feedback_error_send, e.message.orEmpty()),
|
||||||
// Updated reference to id
|
// Updated reference to id
|
||||||
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
|
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
|
||||||
chatInputMessage = textToSend
|
chatInputMessage = textToSend
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
||||||
import com.aryan.reader.shared.opds.SharedOpdsSearch
|
import com.aryan.reader.shared.opds.SharedOpdsSearch
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -54,7 +55,12 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load feed: ${e.message}") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = getApplication<Application>().getString(R.string.opds_error_load_feed, e.message.orEmpty())
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +89,8 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body ?: throw Exception("Empty body")
|
val body = response.body
|
||||||
|
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||||
val contentLength = body.contentLength()
|
val contentLength = body.contentLength()
|
||||||
|
|
||||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||||
|
|
@ -121,11 +128,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.e("Download failed: ${response.code}")
|
Timber.e("Download failed: ${response.code}")
|
||||||
_uiState.update { it.copy(errorMessage = "Download failed: ${response.message}") }
|
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Download error")
|
Timber.e(e, "Download error")
|
||||||
_uiState.update { it.copy(errorMessage = "Download error: ${e.message}") }
|
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) }
|
||||||
} finally {
|
} finally {
|
||||||
_downloadingState.update { it - entry.id }
|
_downloadingState.update { it - entry.id }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,8 +255,17 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
||||||
|
|
||||||
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||||
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
|
||||||
|
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
|
||||||
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
private const val EXPLICIT_NAVIGATION_SHIFT_ANCHOR_WINDOW_MS = 10_000L
|
||||||
|
|
||||||
|
private fun highlightDiagSnippet(text: String, maxLength: Int = 80): String {
|
||||||
|
return text
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', ' ')
|
||||||
|
.replace('\t', ' ')
|
||||||
|
.take(maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||||
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +334,14 @@ private fun isBlockSelectedOnPage(
|
||||||
return afterStart && beforeEnd
|
return afterStart && beforeEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun highlightsForPaginatedPage(
|
||||||
|
pageChapterIndex: Int?,
|
||||||
|
userHighlights: List<UserHighlight>
|
||||||
|
): List<UserHighlight> {
|
||||||
|
if (pageChapterIndex == null) return emptyList()
|
||||||
|
return userHighlights.filter { it.chapterIndex == pageChapterIndex }
|
||||||
|
}
|
||||||
|
|
||||||
class ReactiveBlockMap(
|
class ReactiveBlockMap(
|
||||||
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
|
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
|
||||||
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
|
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
|
||||||
|
|
@ -939,6 +956,7 @@ fun PaginatedReaderScreen(
|
||||||
when (debouncedTextAlign) {
|
when (debouncedTextAlign) {
|
||||||
ReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
ReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
||||||
ReaderTextAlign.LEFT -> TextAlign.Left
|
ReaderTextAlign.LEFT -> TextAlign.Left
|
||||||
|
ReaderTextAlign.RIGHT -> TextAlign.Right
|
||||||
ReaderTextAlign.DEFAULT -> null
|
ReaderTextAlign.DEFAULT -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1266,6 +1284,7 @@ fun PaginatedReaderScreen(
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
},
|
},
|
||||||
|
onGetChapterIndex = { pageIndex -> paginator.findChapterIndexForPage(pageIndex) },
|
||||||
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
|
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
|
||||||
onGetChapterInfo = { pageIndex ->
|
onGetChapterInfo = { pageIndex ->
|
||||||
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
|
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
|
||||||
|
|
@ -1522,8 +1541,12 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
.takeIf { it > blockStartAbs }
|
.takeIf { it > blockStartAbs }
|
||||||
?: (blockStartAbs + block.content.text.length)
|
?: (blockStartAbs + block.content.text.length)
|
||||||
|
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
"getHighlightOffsetsInBlock: Checking Block=${block.cfi} (AbsStart=$blockStartAbs) against Highlight=${highlight.cfi}"
|
"map_check blockCfi=${block.cfi} blockPath=$blockPath " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs blockLen=${block.content.text.length} " +
|
||||||
|
"highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " +
|
||||||
|
"highlightCfi=${highlight.cfi} startCfi=$startCfi endCfi=$endCfi " +
|
||||||
|
"highlightTextLen=${highlight.text.length} highlightText='${highlightDiagSnippet(highlight.text)}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
val relevantPart = parts.find { cfiPart ->
|
val relevantPart = parts.find { cfiPart ->
|
||||||
|
|
@ -1559,8 +1582,8 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relevantPart != null) {
|
if (relevantPart != null) {
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
" -> Block ${block.cfi} matches specific part of multipart highlight: $relevantPart"
|
"map_relevant_part blockCfi=${block.cfi} highlightId=${highlight.id} part=$relevantPart"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1573,16 +1596,38 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
isMultipartHighlight &&
|
isMultipartHighlight &&
|
||||||
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
CfiUtils.isPathStrictlyBetween(block.cfi!!, startCfi, endCfi!!)
|
||||||
|
|
||||||
Timber.d(" -> relevantPart=$relevantPart, isIntermediateBlock=$isIntermediateBlock")
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_decision blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||||
|
"relevantPart=$relevantPart isIntermediateBlock=$isIntermediateBlock"
|
||||||
|
)
|
||||||
|
|
||||||
if (relevantPart == null) {
|
if (relevantPart == null) {
|
||||||
if (!isIntermediateBlock) return null
|
if (!isIntermediateBlock) return null
|
||||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
if (highlightText.contains(blockText, ignoreCase = false)) {
|
||||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
val range = 0 until blockText.length
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=intermediate_exact blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
if (highlightText.contains(blockText, ignoreCase = true)) {
|
||||||
|
val range = 0 until blockText.length
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=intermediate_exact_ignore_case blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
|
}
|
||||||
val normBlock = blockText.filter { !it.isWhitespace() }
|
val normBlock = blockText.filter { !it.isWhitespace() }
|
||||||
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
val normHighlight = highlightText.filter { !it.isWhitespace() }
|
||||||
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
return if (normBlock.isNotBlank() && normHighlight.contains(normBlock, ignoreCase = true)) {
|
||||||
0 until blockText.length
|
val range = 0 until blockText.length
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=intermediate_normalized blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
range
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -1604,18 +1649,26 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
val startMatches = arePathsEquivalent(startCfi, block.cfi!!)
|
val startMatches = arePathsEquivalent(startCfi, block.cfi!!)
|
||||||
val endMatches = if (endCfi != null) arePathsEquivalent(endCfi, block.cfi!!) else false
|
val endMatches = if (endCfi != null) arePathsEquivalent(endCfi, block.cfi!!) else false
|
||||||
|
|
||||||
Timber.d(" -> Path Equivalence: StartMatches=$startMatches, EndMatches=$endMatches")
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_path_equivalence blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||||
|
"startMatches=$startMatches endMatches=$endMatches"
|
||||||
|
)
|
||||||
|
|
||||||
if (startMatches || endMatches) {
|
if (startMatches || endMatches) {
|
||||||
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
val startAbs = CfiUtils.getOffsetOrNull(startCfi)
|
||||||
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
val endAbs = endCfi?.let { CfiUtils.getOffsetOrNull(it) }
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_offset_inputs blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs cfiOffsets=$startAbs..$endAbs"
|
||||||
|
)
|
||||||
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
if (startMatches && endMatches && startAbs != null && endAbs != null) {
|
||||||
val rangeStartAbs = minOf(startAbs, endAbs)
|
val rangeStartAbs = minOf(startAbs, endAbs)
|
||||||
val rangeEndAbs = maxOf(startAbs, endAbs)
|
val rangeEndAbs = maxOf(startAbs, endAbs)
|
||||||
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
if (rangeEndAbs <= blockStartAbs || rangeStartAbs >= blockEndAbs) {
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
" -> Skipping same-path split block outside highlight offsets. " +
|
"map_skip reason=same_path_split_outside_offsets blockCfi=${block.cfi} " +
|
||||||
"highlight=$rangeStartAbs..$rangeEndAbs block=$blockStartAbs..$blockEndAbs"
|
"highlightId=${highlight.id} highlightAbs=$rangeStartAbs..$rangeEndAbs " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs"
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -1653,8 +1706,9 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
val targetRel = relOffset - safeStart
|
val targetRel = relOffset - safeStart
|
||||||
val bestRel = matches.minByOrNull { abs(it - targetRel) }!!
|
val bestRel = matches.minByOrNull { abs(it - targetRel) }!!
|
||||||
val newS = safeStart + bestRel
|
val newS = safeStart + bestRel
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
"Snapped start offset from rel $relOffset to $newS based on prefix '$prefix'"
|
"map_snap_start blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||||
|
"fromRel=$relOffset toRel=$newS prefix='$prefix'"
|
||||||
)
|
)
|
||||||
s = newS
|
s = newS
|
||||||
snapped = true
|
snapped = true
|
||||||
|
|
@ -1674,8 +1728,9 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
val absOffset = endAbs ?: CfiUtils.getOffset(endCfi!!)
|
||||||
val relOffset = absOffset - blockStartAbs
|
val relOffset = absOffset - blockStartAbs
|
||||||
|
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
" -> EndCFI Match. AbsOffset: $absOffset. RelOffset: $relOffset. Block Length: ${blockText.length}"
|
"map_end_match blockCfi=${block.cfi} highlightId=${highlight.id} " +
|
||||||
|
"absOffset=$absOffset relOffset=$relOffset blockLen=${blockText.length}"
|
||||||
)
|
)
|
||||||
|
|
||||||
e = if (relOffset > blockText.length) {
|
e = if (relOffset > blockText.length) {
|
||||||
|
|
@ -1689,19 +1744,38 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
e = e.coerceIn(0, blockText.length)
|
e = e.coerceIn(0, blockText.length)
|
||||||
|
|
||||||
if (s < e) {
|
if (s < e) {
|
||||||
Timber.d("Fallback to CFI offsets for block ${block.cfi}. Range: $s..$e")
|
val range = s until e
|
||||||
return s until e
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=cfi_offsets blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w(
|
||||||
" -> Invalid Range detected (likely highlight is on other split part): $s..$e"
|
"map_skip reason=invalid_range blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$s..$e"
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (highlightText.contains(blockText, ignoreCase = false)) return 0 until blockText.length
|
if (highlightText.contains(blockText, ignoreCase = false)) {
|
||||||
if (highlightText.contains(blockText, ignoreCase = true)) return 0 until blockText.length
|
val range = 0 until blockText.length
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=block_inside_highlight_text blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
if (highlightText.contains(blockText, ignoreCase = true)) {
|
||||||
|
val range = 0 until blockText.length
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=block_inside_highlight_text_ignore_case blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
var startIndex = blockText.indexOf(highlightText, ignoreCase = false)
|
||||||
if (startIndex == -1) {
|
if (startIndex == -1) {
|
||||||
|
|
@ -1709,15 +1783,27 @@ internal fun getHighlightOffsetsInBlock(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startIndex >= 0) {
|
if (startIndex >= 0) {
|
||||||
return startIndex until (startIndex + highlightText.length)
|
val range = startIndex until (startIndex + highlightText.length)
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=highlight_text_inside_block blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$range"
|
||||||
|
)
|
||||||
|
return range
|
||||||
}
|
}
|
||||||
|
|
||||||
val match = findFuzzyMatch(blockText, highlightText)
|
val match = findFuzzyMatch(blockText, highlightText)
|
||||||
if (match != null) return match
|
if (match != null) {
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"map_result reason=fuzzy_text blockCfi=${block.cfi} " +
|
||||||
|
"blockAbs=$blockStartAbs..$blockEndAbs highlightId=${highlight.id} range=$match"
|
||||||
|
)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
if (relevantPart != null) {
|
if (relevantPart != null) {
|
||||||
Timber.d(
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
"Failed to match highlight text in block despite CFI match. " + "BlockCfi=${block.cfi}, HighlightCfi=${highlight.cfi}. "
|
"map_skip reason=cfi_match_text_miss blockCfi=${block.cfi} " +
|
||||||
|
"highlightId=${highlight.id} highlightCfi=${highlight.cfi}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1781,6 +1867,17 @@ private fun TextWithEmphasis(
|
||||||
val range = getHighlightOffsetsInBlock(block, highlight)
|
val range = getHighlightOffsetsInBlock(block, highlight)
|
||||||
if (range != null) {
|
if (range != null) {
|
||||||
try {
|
try {
|
||||||
|
val blockStartAbs = getTextBlockCharOffset(block)
|
||||||
|
val blockEndAbs = block.endCharOffsetInSource
|
||||||
|
.takeIf { it > blockStartAbs }
|
||||||
|
?: (blockStartAbs + block.content.text.length)
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"draw_highlight page=$pageIndex blockCfi=${block.cfi} " +
|
||||||
|
"blockIndex=${block.blockIndex} blockAbs=$blockStartAbs..$blockEndAbs " +
|
||||||
|
"highlightId=${highlight.id} highlightChapter=${highlight.chapterIndex} " +
|
||||||
|
"highlightCfi=${highlight.cfi} range=$range " +
|
||||||
|
"blockText='${highlightDiagSnippet(block.content.text)}'"
|
||||||
|
)
|
||||||
val path = layout.getPathForRange(range.first, range.last + 1)
|
val path = layout.getPathForRange(range.first, range.last + 1)
|
||||||
paths.add(path to highlight.color.color.copy(alpha = 0.4f))
|
paths.add(path to highlight.color.color.copy(alpha = 0.4f))
|
||||||
if (highlight.cfi == pressedHighlightCfi) {
|
if (highlight.cfi == pressedHighlightCfi) {
|
||||||
|
|
@ -2064,6 +2161,13 @@ private fun TextWithEmphasis(
|
||||||
val range = getHighlightOffsetsInBlock(block, highlight) ?: continue
|
val range = getHighlightOffsetsInBlock(block, highlight) ?: continue
|
||||||
|
|
||||||
if (charOffset in range) {
|
if (charOffset in range) {
|
||||||
|
val blockStartAbs = getTextBlockCharOffset(block)
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"tap_highlight page=$pageIndex blockCfi=${block.cfi} " +
|
||||||
|
"blockIndex=${block.blockIndex} blockAbsStart=$blockStartAbs " +
|
||||||
|
"charOffset=$charOffset absoluteCharOffset=${blockStartAbs + charOffset} " +
|
||||||
|
"highlightId=${highlight.id} highlightCfi=${highlight.cfi} range=$range"
|
||||||
|
)
|
||||||
val path = layout.getPathForRange(range.first, range.last)
|
val path = layout.getPathForRange(range.first, range.last)
|
||||||
val bounds = path.getBounds()
|
val bounds = path.getBounds()
|
||||||
return highlight to bounds
|
return highlight to bounds
|
||||||
|
|
@ -2222,6 +2326,7 @@ internal fun PaginatedReaderContent(
|
||||||
horizontalPadding: Dp,
|
horizontalPadding: Dp,
|
||||||
verticalPadding: Dp,
|
verticalPadding: Dp,
|
||||||
onGetPage: (Int) -> Page?,
|
onGetPage: (Int) -> Page?,
|
||||||
|
onGetChapterIndex: (Int) -> Int?,
|
||||||
onGetChapterPath: (Int) -> String?,
|
onGetChapterPath: (Int) -> String?,
|
||||||
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
onLinkClick: (currentChapterPath: String, href: String, onNavComplete: (Int) -> Unit) -> Unit,
|
||||||
onInternalLinkNavigated: (Int) -> Unit,
|
onInternalLinkNavigated: (Int) -> Unit,
|
||||||
|
|
@ -2393,6 +2498,11 @@ internal fun PaginatedReaderContent(
|
||||||
|
|
||||||
var pageContent by remember { mutableStateOf<Page?>(null) }
|
var pageContent by remember { mutableStateOf<Page?>(null) }
|
||||||
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
var currentChapterPath by remember { mutableStateOf<String?>(null) }
|
||||||
|
val pageChapterIndex = onGetChapterIndex(pageIndex)
|
||||||
|
val pageUserHighlights = highlightsForPaginatedPage(
|
||||||
|
pageChapterIndex = pageChapterIndex,
|
||||||
|
userHighlights = userHighlights
|
||||||
|
)
|
||||||
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
|
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
|
||||||
pageContent?.applyReaderThemeForDisplay(
|
pageContent?.applyReaderThemeForDisplay(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|
@ -2401,6 +2511,15 @@ internal fun PaginatedReaderContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageUserHighlights.size != userHighlights.size) {
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"page_scope page=$pageIndex pageChapter=$pageChapterIndex " +
|
||||||
|
"inputHighlightCount=${userHighlights.size} " +
|
||||||
|
"pageHighlightCount=${pageUserHighlights.size} " +
|
||||||
|
"inputHighlightChapters=${userHighlights.map { it.chapterIndex }.distinct()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(pageIndex, uiState.generation) {
|
LaunchedEffect(pageIndex, uiState.generation) {
|
||||||
val fetchStartTime = System.currentTimeMillis()
|
val fetchStartTime = System.currentTimeMillis()
|
||||||
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
|
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
|
||||||
|
|
@ -2856,7 +2975,7 @@ internal fun PaginatedReaderContent(
|
||||||
onLinkClick = onLinkClickCallback,
|
onLinkClick = onLinkClickCallback,
|
||||||
onGeneralTap = onGeneralTapCallback,
|
onGeneralTap = onGeneralTapCallback,
|
||||||
block = block,
|
block = block,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection = sel
|
activeSelection = sel
|
||||||
|
|
@ -2939,7 +3058,7 @@ internal fun PaginatedReaderContent(
|
||||||
onLinkClick = onLinkClickCallback,
|
onLinkClick = onLinkClickCallback,
|
||||||
onGeneralTap = onGeneralTapCallback,
|
onGeneralTap = onGeneralTapCallback,
|
||||||
block = block,
|
block = block,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection = sel
|
activeSelection = sel
|
||||||
|
|
@ -3025,7 +3144,7 @@ internal fun PaginatedReaderContent(
|
||||||
onLinkClick = onLinkClickCallback,
|
onLinkClick = onLinkClickCallback,
|
||||||
onGeneralTap = onGeneralTapCallback,
|
onGeneralTap = onGeneralTapCallback,
|
||||||
block = block,
|
block = block,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection = sel
|
activeSelection = sel
|
||||||
|
|
@ -3142,7 +3261,7 @@ internal fun PaginatedReaderContent(
|
||||||
onLinkClick = onLinkClickCallback,
|
onLinkClick = onLinkClickCallback,
|
||||||
onGeneralTap = onGeneralTapCallback,
|
onGeneralTap = onGeneralTapCallback,
|
||||||
block = block,
|
block = block,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection = sel
|
activeSelection = sel
|
||||||
|
|
@ -3210,7 +3329,7 @@ internal fun PaginatedReaderContent(
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
onLinkClickCallback = onLinkClickCallback,
|
onLinkClickCallback = onLinkClickCallback,
|
||||||
onGeneralTapCallback = onGeneralTapCallback,
|
onGeneralTapCallback = onGeneralTapCallback,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection =
|
activeSelection =
|
||||||
|
|
@ -3263,7 +3382,7 @@ internal fun PaginatedReaderContent(
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
onLinkClickCallback = onLinkClickCallback,
|
onLinkClickCallback = onLinkClickCallback,
|
||||||
onGeneralTapCallback = onGeneralTapCallback,
|
onGeneralTapCallback = onGeneralTapCallback,
|
||||||
userHighlights = userHighlights,
|
userHighlights = pageUserHighlights,
|
||||||
activeSelection = activeSelection,
|
activeSelection = activeSelection,
|
||||||
onSelectionChange = { sel ->
|
onSelectionChange = { sel ->
|
||||||
activeSelection =
|
activeSelection =
|
||||||
|
|
@ -3838,15 +3957,45 @@ internal fun PaginatedReaderContent(
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
},
|
},
|
||||||
onHighlight = { color ->
|
onHighlight = { color ->
|
||||||
|
val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset
|
||||||
|
val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset
|
||||||
val finalCfi =
|
val finalCfi =
|
||||||
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
||||||
|
val absoluteCandidateCfi =
|
||||||
|
"${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset"
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"create_request source=highlight_menu color=${color.id} " +
|
||||||
|
"savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " +
|
||||||
|
"startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " +
|
||||||
|
"startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " +
|
||||||
|
"startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " +
|
||||||
|
"localOffsets=${sel.startOffset}..${sel.endOffset} " +
|
||||||
|
"blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " +
|
||||||
|
"absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " +
|
||||||
|
"textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'"
|
||||||
|
)
|
||||||
onHighlightCreated(finalCfi, sel.text, color.id)
|
onHighlightCreated(finalCfi, sel.text, color.id)
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
},
|
},
|
||||||
onNote = {
|
onNote = {
|
||||||
onNoteRequested(null)
|
onNoteRequested(null)
|
||||||
|
val startAbsoluteOffset = sel.startBlockCharOffset + sel.startOffset
|
||||||
|
val endAbsoluteOffset = sel.endBlockCharOffset + sel.endOffset
|
||||||
val finalCfi =
|
val finalCfi =
|
||||||
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
"${sel.startBaseCfi}:${sel.startOffset}|${sel.endBaseCfi}:${sel.endOffset}"
|
||||||
|
val absoluteCandidateCfi =
|
||||||
|
"${sel.startBaseCfi}:$startAbsoluteOffset|${sel.endBaseCfi}:$endAbsoluteOffset"
|
||||||
|
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
|
||||||
|
"create_request source=note_menu color=${HighlightColor.YELLOW.id} " +
|
||||||
|
"savedCfi=$finalCfi absoluteCandidateCfi=$absoluteCandidateCfi " +
|
||||||
|
"startPage=${sel.startPageIndex} endPage=${sel.endPageIndex} " +
|
||||||
|
"startBlockIndex=${sel.startBlockIndex} endBlockIndex=${sel.endBlockIndex} " +
|
||||||
|
"startBaseCfi=${sel.startBaseCfi} endBaseCfi=${sel.endBaseCfi} " +
|
||||||
|
"localOffsets=${sel.startOffset}..${sel.endOffset} " +
|
||||||
|
"blockAbsStarts=${sel.startBlockCharOffset}..${sel.endBlockCharOffset} " +
|
||||||
|
"absoluteOffsets=$startAbsoluteOffset..$endAbsoluteOffset " +
|
||||||
|
"textLen=${sel.text.length} text='${highlightDiagSnippet(sel.text)}'"
|
||||||
|
)
|
||||||
onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id)
|
onHighlightCreated(finalCfi, sel.text, HighlightColor.YELLOW.id)
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Description
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -40,6 +43,7 @@ import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
|
@ -58,6 +62,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -76,6 +81,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.pdf.data.VirtualPage
|
import com.aryan.reader.pdf.data.VirtualPage
|
||||||
|
|
||||||
private const val MAX_FIXED_RECURSION = 128
|
private const val MAX_FIXED_RECURSION = 128
|
||||||
|
|
@ -84,6 +90,32 @@ internal data class PdfBookmark(val pageIndex: Int, val title: String, val total
|
||||||
|
|
||||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||||
|
|
||||||
|
private enum class PdfDrawerSection {
|
||||||
|
TABS,
|
||||||
|
CHAPTERS,
|
||||||
|
BOOKMARKS,
|
||||||
|
HIGHLIGHTS,
|
||||||
|
PAGES
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PdfDrawerSection.titleResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
PdfDrawerSection.TABS -> R.string.tab_tabs
|
||||||
|
PdfDrawerSection.CHAPTERS -> R.string.tab_chapters
|
||||||
|
PdfDrawerSection.BOOKMARKS -> R.string.tab_bookmarks
|
||||||
|
PdfDrawerSection.HIGHLIGHTS -> R.string.tab_highlights
|
||||||
|
PdfDrawerSection.PAGES -> R.string.tab_pages
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PdfDrawerSection.testTag: String?
|
||||||
|
get() = when (this) {
|
||||||
|
PdfDrawerSection.TABS -> "TabsTab"
|
||||||
|
PdfDrawerSection.BOOKMARKS -> "BookmarksTab"
|
||||||
|
PdfDrawerSection.HIGHLIGHTS -> "HighlightsTab"
|
||||||
|
PdfDrawerSection.PAGES -> "PagesTab"
|
||||||
|
PdfDrawerSection.CHAPTERS -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||||
*/
|
*/
|
||||||
|
|
@ -278,6 +310,225 @@ internal fun PdfTocTreeItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfTabsDrawerPage(
|
||||||
|
openTabs: List<RecentFileItem>,
|
||||||
|
activeTabBookId: String?,
|
||||||
|
currentPage: Int,
|
||||||
|
totalPages: Int,
|
||||||
|
onTabSelected: (String) -> Unit,
|
||||||
|
onTabClosed: (String) -> Unit,
|
||||||
|
onNewTabClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, top = 10.dp, end = 8.dp, bottom = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.active_tabs),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = openTabs.size.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onNewTabClick,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_new_tab)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (openTabs.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.msg_no_other_pdfs_found),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(openTabs, key = { it.bookId }) { tab ->
|
||||||
|
PdfDrawerTabItem(
|
||||||
|
tab = tab,
|
||||||
|
isSelected = tab.bookId == activeTabBookId,
|
||||||
|
currentPage = currentPage,
|
||||||
|
totalPages = totalPages,
|
||||||
|
onTabSelected = onTabSelected,
|
||||||
|
onTabClosed = onTabClosed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfDrawerTabItem(
|
||||||
|
tab: RecentFileItem,
|
||||||
|
isSelected: Boolean,
|
||||||
|
currentPage: Int,
|
||||||
|
totalPages: Int,
|
||||||
|
onTabSelected: (String) -> Unit,
|
||||||
|
onTabClosed: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
val containerColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "PdfDrawerTabContainer"
|
||||||
|
)
|
||||||
|
val borderColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)
|
||||||
|
},
|
||||||
|
label = "PdfDrawerTabBorder"
|
||||||
|
)
|
||||||
|
val contentColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
val progressPercent = remember(isSelected, currentPage, totalPages, tab.progressPercentage) {
|
||||||
|
when {
|
||||||
|
isSelected && totalPages > 0 -> (((currentPage + 1).toFloat() / totalPages.toFloat()) * 100f)
|
||||||
|
.coerceIn(0f, 100f)
|
||||||
|
.toInt()
|
||||||
|
else -> tab.progressPercentage
|
||||||
|
?.coerceIn(0f, 100f)
|
||||||
|
?.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pageLabel = when {
|
||||||
|
isSelected && totalPages > 0 -> stringResource(R.string.page_of_pages, currentPage + 1, totalPages)
|
||||||
|
tab.lastPage != null -> stringResource(R.string.pdf_page_short, tab.lastPage + 1)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val progressLabel = progressPercent
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { stringResource(R.string.progress_complete, it) }
|
||||||
|
val supportingText = remember(pageLabel, progressLabel, tab.author) {
|
||||||
|
listOfNotNull(pageLabel, progressLabel, tab.author)
|
||||||
|
.distinct()
|
||||||
|
.joinToString(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(1.dp, borderColor, shape)
|
||||||
|
.clickable { onTabSelected(tab.bookId) }
|
||||||
|
.testTag("PdfDrawerTab_${tab.bookId}")
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 68.dp)
|
||||||
|
.padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Description,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (supportingText.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.72f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { onTabClosed(tab.bookId) },
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.close_tab),
|
||||||
|
tint = contentColor.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressPercent
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { percent ->
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { percent / 100f },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||||
|
trackColor = Color.Transparent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfNavigationDrawerContent(
|
internal fun PdfNavigationDrawerContent(
|
||||||
|
|
@ -288,58 +539,81 @@ internal fun PdfNavigationDrawerContent(
|
||||||
userHighlights: List<PdfUserHighlight>,
|
userHighlights: List<PdfUserHighlight>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
|
isTabsEnabled: Boolean = false,
|
||||||
|
openTabs: List<RecentFileItem> = emptyList(),
|
||||||
|
activeTabBookId: String? = null,
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||||
onPageSelected: (Int) -> Unit,
|
onPageSelected: (Int) -> Unit,
|
||||||
|
onTabSelected: (String) -> Unit = {},
|
||||||
|
onTabClosed: (String) -> Unit = {},
|
||||||
|
onNewTabClick: () -> Unit = {},
|
||||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||||
onNoteRequested: (String?) -> Unit,
|
onNoteRequested: (String?) -> Unit,
|
||||||
onCloseDrawer: () -> Unit
|
onCloseDrawer: () -> Unit
|
||||||
) {
|
) {
|
||||||
val drawerPagerState = rememberPagerState(pageCount = { 4 })
|
val showTabsPane = isTabsEnabled && openTabs.isNotEmpty()
|
||||||
|
val drawerSections = remember(showTabsPane) {
|
||||||
|
buildList {
|
||||||
|
if (showTabsPane) add(PdfDrawerSection.TABS)
|
||||||
|
add(PdfDrawerSection.CHAPTERS)
|
||||||
|
add(PdfDrawerSection.BOOKMARKS)
|
||||||
|
add(PdfDrawerSection.HIGHLIGHTS)
|
||||||
|
add(PdfDrawerSection.PAGES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val drawerPagerState = rememberPagerState(pageCount = { drawerSections.size })
|
||||||
val drawerScope = rememberCoroutineScope()
|
val drawerScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(drawerSections.size) {
|
||||||
|
if (drawerPagerState.currentPage >= drawerSections.size) {
|
||||||
|
drawerPagerState.scrollToPage(drawerSections.lastIndex.coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val selectedDrawerTabIndex = drawerPagerState.currentPage.coerceIn(0, drawerSections.lastIndex)
|
||||||
ScrollableTabRow(
|
ScrollableTabRow(
|
||||||
selectedTabIndex = drawerPagerState.currentPage,
|
selectedTabIndex = selectedDrawerTabIndex,
|
||||||
edgePadding = 8.dp,
|
edgePadding = 8.dp,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
drawerSections.forEachIndexed { index, section ->
|
||||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
|
||||||
}, text = { Text(stringResource(R.string.tab_chapters)) })
|
|
||||||
Tab(
|
Tab(
|
||||||
selected = drawerPagerState.currentPage == 1,
|
selected = selectedDrawerTabIndex == index,
|
||||||
onClick = {
|
onClick = {
|
||||||
drawerScope.launch { drawerPagerState.animateScrollToPage(1) }
|
drawerScope.launch { drawerPagerState.animateScrollToPage(index) }
|
||||||
},
|
},
|
||||||
text = { Text(stringResource(R.string.tab_bookmarks)) },
|
text = { Text(stringResource(section.titleResId)) },
|
||||||
modifier = Modifier.testTag("BookmarksTab")
|
modifier = section.testTag?.let { Modifier.testTag(it) } ?: Modifier
|
||||||
)
|
|
||||||
Tab(
|
|
||||||
selected = drawerPagerState.currentPage == 2,
|
|
||||||
onClick = {
|
|
||||||
drawerScope.launch { drawerPagerState.animateScrollToPage(2) }
|
|
||||||
},
|
|
||||||
text = { Text(stringResource(R.string.tab_highlights)) },
|
|
||||||
modifier = Modifier.testTag("HighlightsTab")
|
|
||||||
)
|
|
||||||
Tab(
|
|
||||||
selected = drawerPagerState.currentPage == 3,
|
|
||||||
onClick = {
|
|
||||||
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
|
|
||||||
},
|
|
||||||
text = { Text(stringResource(R.string.tab_pages)) },
|
|
||||||
modifier = Modifier.testTag("PagesTab")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = drawerPagerState,
|
state = drawerPagerState,
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
) { page ->
|
) { page ->
|
||||||
when (page) {
|
when (drawerSections[page]) {
|
||||||
0 -> { // Chapters Page
|
PdfDrawerSection.TABS -> PdfTabsDrawerPage(
|
||||||
|
openTabs = openTabs,
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
|
currentPage = currentPage,
|
||||||
|
totalPages = totalPages,
|
||||||
|
onTabSelected = { bookId ->
|
||||||
|
if (bookId == activeTabBookId) {
|
||||||
|
onCloseDrawer()
|
||||||
|
} else {
|
||||||
|
onCloseDrawer()
|
||||||
|
onTabSelected(bookId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTabClosed = onTabClosed,
|
||||||
|
onNewTabClick = onNewTabClick
|
||||||
|
)
|
||||||
|
|
||||||
|
PdfDrawerSection.CHAPTERS -> { // Chapters Page
|
||||||
if (flatTableOfContents.isEmpty()) {
|
if (flatTableOfContents.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
|
@ -500,7 +774,7 @@ internal fun PdfNavigationDrawerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
1 -> { // Bookmarks Page
|
PdfDrawerSection.BOOKMARKS -> { // Bookmarks Page
|
||||||
if (bookmarks.isEmpty()) {
|
if (bookmarks.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -642,7 +916,7 @@ internal fun PdfNavigationDrawerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2 -> { // Highlights Page
|
PdfDrawerSection.HIGHLIGHTS -> { // Highlights Page
|
||||||
if (userHighlights.isEmpty()) {
|
if (userHighlights.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -801,7 +1075,7 @@ internal fun PdfNavigationDrawerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3 -> { // Pages Page
|
PdfDrawerSection.PAGES -> { // Pages Page
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -236,6 +237,9 @@ internal fun PdfSelectionMenuPopup(
|
||||||
onNote: (() -> Unit)? = null
|
onNote: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp)
|
||||||
|
val menuScrollState = rememberScrollState()
|
||||||
|
|
||||||
Popup(
|
Popup(
|
||||||
popupPositionProvider = popupPositionProvider,
|
popupPositionProvider = popupPositionProvider,
|
||||||
|
|
@ -251,9 +255,15 @@ internal fun PdfSelectionMenuPopup(
|
||||||
shadowElevation = 8.dp,
|
shadowElevation = 8.dp,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
modifier = Modifier.widthIn(max = 300.dp)
|
modifier = Modifier
|
||||||
|
.widthIn(max = 280.dp)
|
||||||
|
.heightIn(max = selectionMenuMaxHeight)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = (if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max))
|
||||||
|
.heightIn(max = selectionMenuMaxHeight)
|
||||||
|
.verticalScroll(menuScrollState)
|
||||||
) {
|
) {
|
||||||
Column(modifier = if (menuState.isComment) Modifier.fillMaxWidth() else Modifier.width(IntrinsicSize.Max)) {
|
|
||||||
if (!menuState.note.isNullOrBlank()) {
|
if (!menuState.note.isNullOrBlank()) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
|
@ -330,7 +340,7 @@ internal fun PdfSelectionMenuPopup(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp)
|
modifier = Modifier.padding(vertical = 8.dp, horizontal = 10.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -338,7 +348,7 @@ internal fun PdfSelectionMenuPopup(
|
||||||
PdfHighlightColor.entries.forEach { colorEnum ->
|
PdfHighlightColor.entries.forEach { colorEnum ->
|
||||||
val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color
|
val displayColor = customHighlightColors[colorEnum] ?: colorEnum.color
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.padding(horizontal = 6.dp).size(32.dp)
|
modifier = Modifier.padding(horizontal = 4.dp).size(28.dp)
|
||||||
.background(displayColor, CircleShape).clip(CircleShape)
|
.background(displayColor, CircleShape).clip(CircleShape)
|
||||||
.clickable {
|
.clickable {
|
||||||
Timber.tag("PdfHighlightDebug")
|
Timber.tag("PdfHighlightDebug")
|
||||||
|
|
@ -352,8 +362,8 @@ internal fun PdfSelectionMenuPopup(
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 6.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.size(32.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Brush.sweepGradient(rainbowColors))
|
.background(Brush.sweepGradient(rainbowColors))
|
||||||
.clickable { onPaletteClick() },
|
.clickable { onPaletteClick() },
|
||||||
|
|
@ -392,7 +402,7 @@ internal fun PdfSelectionMenuPopup(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -400,22 +410,22 @@ internal fun PdfSelectionMenuPopup(
|
||||||
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(64.dp)
|
.width(56.dp)
|
||||||
.clickable { action.onClick() }
|
.clickable { action.onClick() }
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 6.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (action.imageVector != null) {
|
if (action.imageVector != null) {
|
||||||
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
} else if (action.iconRes != null) {
|
} else if (action.iconRes != null) {
|
||||||
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repeat(3 - rowActions.size) {
|
repeat(3 - rowActions.size) {
|
||||||
Spacer(modifier = Modifier.width(64.dp))
|
Spacer(modifier = Modifier.width(56.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,10 @@ import com.aryan.reader.pdf.data.PdfTextBox
|
||||||
import com.aryan.reader.pdf.data.VirtualPage
|
import com.aryan.reader.pdf.data.VirtualPage
|
||||||
import com.aryan.reader.pdf.ocr.OcrElement
|
import com.aryan.reader.pdf.ocr.OcrElement
|
||||||
import com.aryan.reader.pdf.ocr.OcrResult
|
import com.aryan.reader.pdf.ocr.OcrResult
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||||
|
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -2139,7 +2143,7 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
|
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
val dragWorker = coroutineScope.launch(Dispatchers.IO) {
|
||||||
var pageForDrag: ReaderPage? = null
|
var pageForDrag: ReaderPage? = null
|
||||||
var textPageForDrag: ReaderTextPage? = null
|
var textPageForDrag: ReaderTextPage? = null
|
||||||
|
|
||||||
|
|
@ -2473,11 +2477,11 @@ internal fun PdfPageComposable(
|
||||||
} finally {
|
} finally {
|
||||||
dragEventChannel.close()
|
dragEventChannel.close()
|
||||||
}
|
}
|
||||||
|
dragWorker.invokeOnCompletion {
|
||||||
|
coroutineScope.launch {
|
||||||
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
||||||
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
||||||
val currentRange = selectionCharRange.value!!
|
val currentRange = selectionCharRange.value!!
|
||||||
coroutineScope.launch {
|
|
||||||
var pageForMenu: ReaderPage? = null
|
var pageForMenu: ReaderPage? = null
|
||||||
var textPageForMenu: ReaderTextPage? = null
|
var textPageForMenu: ReaderTextPage? = null
|
||||||
try {
|
try {
|
||||||
|
|
@ -2523,7 +2527,6 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
}
|
}
|
||||||
|
|
@ -2573,6 +2576,8 @@ internal fun PdfPageComposable(
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState"
|
"PointerInput: Drag on handle completed/cancelled. Menu state: $customMenuState"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
|
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
|
||||||
try {
|
try {
|
||||||
|
|
@ -5632,22 +5637,20 @@ private fun PdfPageRenderer(
|
||||||
val topLeftWindow = coords.localToWindow(topLeftLocal)
|
val topLeftWindow = coords.localToWindow(topLeftLocal)
|
||||||
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
|
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
|
||||||
|
|
||||||
val windowCenterX = (topLeftWindow.x + bottomRightWindow.x) / 2
|
|
||||||
val gapPx = with(density) { 16.dp.toPx() }
|
val gapPx = with(density) { 16.dp.toPx() }
|
||||||
|
val placement = sharedSelectionMenuPlacement(
|
||||||
var yInWindow = (topLeftWindow.y - popupContentSize.height - gapPx).toInt()
|
viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height),
|
||||||
|
popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height),
|
||||||
if (yInWindow < 0) {
|
selection = SharedSelectionMenuRect(
|
||||||
yInWindow = (bottomRightWindow.y + gapPx).toInt()
|
left = topLeftWindow.x,
|
||||||
if (yInWindow + popupContentSize.height > windowSize.height) {
|
top = topLeftWindow.y,
|
||||||
yInWindow = windowSize.height - popupContentSize.height - gapPx.toInt()
|
right = bottomRightWindow.x,
|
||||||
}
|
bottom = bottomRightWindow.y
|
||||||
}
|
),
|
||||||
|
marginPx = gapPx,
|
||||||
val xInWindow = (windowCenterX - popupContentSize.width / 2).toInt()
|
gapPx = gapPx
|
||||||
.coerceIn(0, windowSize.width - popupContentSize.width)
|
)
|
||||||
|
return IntOffset(placement.x, placement.y)
|
||||||
return IntOffset(xInWindow, yInWindow)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.ReaderTheme
|
import com.aryan.reader.ReaderTheme
|
||||||
import com.aryan.reader.ReaderTexture
|
import com.aryan.reader.ReaderTexture
|
||||||
import com.aryan.reader.epubreader.SystemUiMode
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
|
|
@ -49,33 +51,46 @@ internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||||
|
|
||||||
enum class PdfReaderTool(val title: String, val category: String) {
|
enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||||
DICTIONARY("External Apps", "Top Bar"),
|
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||||
THEME("Theme Settings", "Top Bar"),
|
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||||
LOCK_PANNING("Lock Panning", "Top Bar"),
|
LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"),
|
||||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||||
FULL_SCREEN("Full Screen", "Top Bar"),
|
FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"),
|
||||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||||
TOC("Sidebar", "Bottom Bar"),
|
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||||
SEARCH("Search", "Bottom Bar"),
|
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||||
HIGHLIGHT_ALL("Highlight selectable text", "Bottom Bar"),
|
HIGHLIGHT_ALL(R.string.tool_highlight_selectable_text, "Bottom Bar"),
|
||||||
AI_FEATURES("AI Features", "Bottom Bar"),
|
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
|
||||||
EDIT_MODE("Edit Mode", "Bottom Bar"),
|
EDIT_MODE(R.string.tool_edit_mode, "Bottom Bar"),
|
||||||
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
|
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
|
||||||
OCR_LANGUAGE("OCR Language", "Overflow Menu"),
|
OCR_LANGUAGE(R.string.menu_ocr_language, "Overflow Menu"),
|
||||||
READING_MODE("Reading Mode", "Overflow Menu"),
|
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
|
||||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
|
||||||
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
|
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
|
||||||
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
|
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
|
||||||
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
|
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
|
||||||
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
|
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
|
||||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
|
||||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
PAGE_MANAGEMENT(R.string.tool_page_management, "Overflow Menu"),
|
||||||
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
REFLOW(R.string.tool_text_view_reflow, "Overflow Menu"),
|
||||||
SHARE("Share", "Overflow Menu"),
|
SHARE(R.string.action_share, "Overflow Menu"),
|
||||||
SAVE_COPY("Save Copy", "Overflow Menu"),
|
SAVE_COPY(R.string.action_save_copy_to_device, "Overflow Menu"),
|
||||||
PRINT("Print", "Overflow Menu")
|
PRINT(R.string.action_print, "Overflow Menu")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun defaultPdfHiddenTools(): Set<String> {
|
||||||
|
return setOf(
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.toList()
|
||||||
|
|
||||||
|
internal fun defaultPdfBottomTools(): Set<String> {
|
||||||
|
return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val PdfBuiltInThemes = listOf(
|
val PdfBuiltInThemes = listOf(
|
||||||
|
|
@ -99,10 +114,7 @@ internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||||
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||||
val migratedHiddenTools = savedHiddenTools + setOf(
|
val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools()
|
||||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
|
||||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
|
||||||
)
|
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||||
|
|
@ -127,7 +139,7 @@ internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
||||||
?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
|
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||||
|
|
@ -137,7 +149,7 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>)
|
||||||
|
|
||||||
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
val defaultBottomTools = defaultPdfBottomTools()
|
||||||
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
|
@ -34,6 +36,7 @@ import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.LockOpen
|
import androidx.compose.material.icons.filled.LockOpen
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ScreenRotation
|
import androidx.compose.material.icons.filled.ScreenRotation
|
||||||
|
|
@ -45,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -77,7 +81,8 @@ data class PdfFlatToolItem(
|
||||||
val type: PdfFlatItemType,
|
val type: PdfFlatItemType,
|
||||||
val tool: PdfReaderTool? = null,
|
val tool: PdfReaderTool? = null,
|
||||||
val section: PdfToolbarSection? = null,
|
val section: PdfToolbarSection? = null,
|
||||||
val title: String? = null
|
val title: String? = null,
|
||||||
|
@StringRes val titleRes: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
|
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
|
||||||
|
|
@ -92,7 +97,7 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
PdfToolbarSection.entries.forEach { section ->
|
PdfToolbarSection.entries.forEach { section ->
|
||||||
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||||
val tools = sectionMap[section] ?: emptyList()
|
val tools = sectionMap[section] ?: emptyList()
|
||||||
if (tools.isEmpty()) {
|
if (tools.isEmpty()) {
|
||||||
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||||
|
|
@ -108,6 +113,51 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val pdfReorderableToolbarTools = setOf(
|
||||||
|
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||||
|
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||||
|
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||||
|
PdfReaderTool.SCREEN_ORIENTATION
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun buildPdfToolbarItems(
|
||||||
|
hiddenTools: Set<String>,
|
||||||
|
toolOrder: List<PdfReaderTool>,
|
||||||
|
bottomTools: Set<String>
|
||||||
|
): List<PdfFlatToolItem> {
|
||||||
|
val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools }
|
||||||
|
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||||
|
val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||||
|
|
||||||
|
val list = mutableListOf<PdfFlatToolItem>()
|
||||||
|
|
||||||
|
PdfToolbarSection.entries.forEach { section ->
|
||||||
|
val tools = when (section) {
|
||||||
|
PdfToolbarSection.TOP -> topTools
|
||||||
|
PdfToolbarSection.BOTTOM -> bottomToolsList
|
||||||
|
PdfToolbarSection.HIDDEN -> hiddenToolsList
|
||||||
|
}
|
||||||
|
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||||
|
} else {
|
||||||
|
tools.forEach { tool ->
|
||||||
|
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
|
||||||
|
moreTools.forEach { tool ->
|
||||||
|
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
class PdfDragDropState(
|
class PdfDragDropState(
|
||||||
val lazyListState: LazyListState,
|
val lazyListState: LazyListState,
|
||||||
val onMove: (String, String) -> Unit
|
val onMove: (String, String) -> Unit
|
||||||
|
|
@ -142,54 +192,20 @@ fun PdfCustomizeToolsSheet(
|
||||||
onPlacementUpdate: (Set<String>) -> Unit,
|
onPlacementUpdate: (Set<String>) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val reorderableToolbarTools = setOf(
|
|
||||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
|
||||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
|
||||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
|
||||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
|
||||||
PdfReaderTool.SCREEN_ORIENTATION
|
|
||||||
)
|
|
||||||
|
|
||||||
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||||
var flatItems by remember {
|
var flatItems by remember {
|
||||||
mutableStateOf<List<PdfFlatToolItem>>(
|
mutableStateOf<List<PdfFlatToolItem>>(
|
||||||
run {
|
buildPdfToolbarItems(
|
||||||
val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
|
hiddenTools = hiddenTools,
|
||||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
toolOrder = toolOrder,
|
||||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
bottomTools = bottomTools
|
||||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
)
|
||||||
val moreTools = toolOrder.filter { it !in reorderableToolbarTools }
|
|
||||||
|
|
||||||
val list = mutableListOf<PdfFlatToolItem>()
|
|
||||||
|
|
||||||
PdfToolbarSection.entries.forEach { section ->
|
|
||||||
val tools = when(section) {
|
|
||||||
PdfToolbarSection.TOP -> topTools
|
|
||||||
PdfToolbarSection.BOTTOM -> bottomToolsList
|
|
||||||
PdfToolbarSection.HIDDEN -> hiddenToolsList
|
|
||||||
}
|
|
||||||
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
|
||||||
if (tools.isEmpty()) {
|
|
||||||
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
|
||||||
} else {
|
|
||||||
tools.forEach { tool ->
|
|
||||||
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
|
|
||||||
moreTools.forEach { tool ->
|
|
||||||
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
|
|
||||||
}
|
|
||||||
list
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val commitDragDrop = {
|
val commitDragDrop = {
|
||||||
val newHidden = localHiddenTools.filter { toolName ->
|
val newHidden = localHiddenTools.filter { toolName ->
|
||||||
toolOrder.find { it.name == toolName } !in reorderableToolbarTools
|
toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools
|
||||||
}.toMutableSet()
|
}.toMutableSet()
|
||||||
|
|
||||||
val newBottom = mutableSetOf<String>()
|
val newBottom = mutableSetOf<String>()
|
||||||
|
|
@ -247,6 +263,22 @@ fun PdfCustomizeToolsSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val resetToDefault = {
|
||||||
|
val defaultHiddenTools = defaultPdfHiddenTools()
|
||||||
|
val defaultToolOrder = defaultPdfToolOrder()
|
||||||
|
val defaultBottomTools = defaultPdfBottomTools()
|
||||||
|
|
||||||
|
localHiddenTools = defaultHiddenTools
|
||||||
|
flatItems = buildPdfToolbarItems(
|
||||||
|
hiddenTools = defaultHiddenTools,
|
||||||
|
toolOrder = defaultToolOrder,
|
||||||
|
bottomTools = defaultBottomTools
|
||||||
|
)
|
||||||
|
onUpdate(defaultHiddenTools)
|
||||||
|
onPlacementUpdate(defaultBottomTools)
|
||||||
|
onOrderUpdate(defaultToolOrder)
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
|
@ -269,6 +301,11 @@ fun PdfCustomizeToolsSheet(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
TextButton(onClick = resetToDefault) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.action_reset))
|
||||||
|
}
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
}
|
}
|
||||||
|
|
@ -300,8 +337,9 @@ fun PdfCustomizeToolsSheet(
|
||||||
) {
|
) {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
PdfFlatItemType.SECTION_HEADER -> {
|
PdfFlatItemType.SECTION_HEADER -> {
|
||||||
|
val titleRes = item.titleRes
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: "",
|
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -317,7 +355,7 @@ fun PdfCustomizeToolsSheet(
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PdfFlatItemType.TOOL -> {
|
PdfFlatItemType.TOOL -> {
|
||||||
|
|
@ -334,8 +372,9 @@ fun PdfCustomizeToolsSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PdfFlatItemType.MORE_HEADER -> {
|
PdfFlatItemType.MORE_HEADER -> {
|
||||||
|
val titleRes = item.titleRes
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: "More menu",
|
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
||||||
|
|
@ -343,7 +382,7 @@ fun PdfCustomizeToolsSheet(
|
||||||
}
|
}
|
||||||
PdfFlatItemType.MORE_TOOL -> {
|
PdfFlatItemType.MORE_TOOL -> {
|
||||||
PdfMoreToolVisibilityRow(
|
PdfMoreToolVisibilityRow(
|
||||||
title = item.tool!!.title,
|
title = stringResource(item.tool!!.titleRes),
|
||||||
visible = !localHiddenTools.contains(item.tool.name),
|
visible = !localHiddenTools.contains(item.tool.name),
|
||||||
onToggle = {
|
onToggle = {
|
||||||
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
||||||
|
|
@ -386,18 +425,19 @@ private fun PdfToolbarDragRow(
|
||||||
PdfToolPreviewIcon(tool)
|
PdfToolPreviewIcon(tool)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = tool.title,
|
text = stringResource(tool.titleRes),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Menu,
|
Icons.Default.Menu,
|
||||||
contentDescription = "Drag to reorder",
|
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(32.dp)
|
||||||
.padding(12.dp)
|
.padding(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
.pointerInput(tool) {
|
.pointerInput(tool) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { onDragStart() },
|
onDragStart = { onDragStart() },
|
||||||
|
|
@ -453,7 +493,7 @@ private fun PdfToolbarDragRow(
|
||||||
PdfToolPreviewIcon(tool)
|
PdfToolPreviewIcon(tool)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = tool.title,
|
text = stringResource(tool.titleRes),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
|
|
@ -489,27 +529,28 @@ private fun PdfMoreToolVisibilityRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class PdfToolbarSection(val title: String) {
|
enum class PdfToolbarSection(@StringRes val titleRes: Int) {
|
||||||
TOP("Top Bar"),
|
TOP(R.string.toolbar_top_bar),
|
||||||
BOTTOM("Bottom Bar"),
|
BOTTOM(R.string.toolbar_bottom_bar),
|
||||||
HIDDEN("Hidden Tools")
|
HIDDEN(R.string.toolbar_hidden_tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
||||||
|
val title = stringResource(tool.titleRes)
|
||||||
when (tool) {
|
when (tool) {
|
||||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -556,7 +597,7 @@ fun PdfVisualOptionsSheet(
|
||||||
options = SystemUiMode.entries,
|
options = SystemUiMode.entries,
|
||||||
selectedOption = systemUiMode,
|
selectedOption = systemUiMode,
|
||||||
onOptionSelected = onSystemUiModeChange,
|
onOptionSelected = onSystemUiModeChange,
|
||||||
getLabel = { it.title }
|
getLabel = { stringResource(it.titleRes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ internal fun PdfTopBar(
|
||||||
|
|
||||||
if (hiddenToolbarTools.isNotEmpty()) {
|
if (hiddenToolbarTools.isNotEmpty()) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Hidden tools") },
|
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -665,7 +665,7 @@ private fun HiddenPdfToolMenuItem(
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(tool.title) },
|
text = { Text(stringResource(tool.titleRes)) },
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
|
|
|
||||||
|
|
@ -2118,7 +2118,7 @@ fun PdfViewerScreen(
|
||||||
words.take(6).joinToString(" ") + "..."
|
words.take(6).joinToString(" ") + "..."
|
||||||
} else {
|
} else {
|
||||||
Timber.d("No words found. Falling back to 'Page X' title.")
|
Timber.d("No words found. Falling back to 'Page X' title.")
|
||||||
"Page ${pageIndex + 1}"
|
context.getString(R.string.pdf_page_short, pageIndex + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val chapterTitle =
|
val chapterTitle =
|
||||||
|
|
@ -2472,7 +2472,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (virtualPage is VirtualPage.BlankPage) {
|
if (virtualPage is VirtualPage.BlankPage) {
|
||||||
onUpdate(SummarizationResult(error = "Cannot summarize a blank page."))
|
onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_blank_page_summary)))
|
||||||
onFinish()
|
onFinish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2480,7 +2480,7 @@ fun PdfViewerScreen(
|
||||||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: currentPageIndex
|
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: currentPageIndex
|
||||||
|
|
||||||
val doc = pdfDocument ?: run {
|
val doc = pdfDocument ?: run {
|
||||||
onUpdate(SummarizationResult(error = "Document not loaded."))
|
onUpdate(SummarizationResult(error = context.getString(R.string.pdf_error_document_not_loaded)))
|
||||||
onFinish()
|
onFinish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2593,7 +2593,7 @@ fun PdfViewerScreen(
|
||||||
if (fullText.isEmpty() && lastResult?.error == null) {
|
if (fullText.isEmpty() && lastResult?.error == null) {
|
||||||
onUpdate(
|
onUpdate(
|
||||||
SummarizationResult(
|
SummarizationResult(
|
||||||
error = "Failed to parse summary from server response."
|
error = context.getString(R.string.ai_error_parse_summary)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2607,17 +2607,21 @@ fun PdfViewerScreen(
|
||||||
val errorDetail = try {
|
val errorDetail = try {
|
||||||
errorBody?.let { JSONObject(it).getString("detail") }
|
errorBody?.let { JSONObject(it).getString("detail") }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"Could not fetch summary."
|
context.getString(R.string.ai_error_fetch_summary)
|
||||||
}
|
}
|
||||||
onUpdate(
|
onUpdate(
|
||||||
SummarizationResult(
|
SummarizationResult(
|
||||||
error = "Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}"
|
error = context.getString(
|
||||||
|
R.string.ai_error_with_code,
|
||||||
|
responseCode,
|
||||||
|
errorDetail ?: context.getString(R.string.error_unknown_server)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Exception during PDF page summarization: ${e.message}")
|
Timber.e(e, "Exception during PDF page summarization: ${e.message}")
|
||||||
onUpdate(SummarizationResult(error = "An error occurred: ${e.localizedMessage}"))
|
onUpdate(SummarizationResult(error = context.getString(R.string.error_occurred_format, e.localizedMessage)))
|
||||||
} finally {
|
} finally {
|
||||||
pageBitmap?.recycle()
|
pageBitmap?.recycle()
|
||||||
connection?.disconnect()
|
connection?.disconnect()
|
||||||
|
|
@ -2783,8 +2787,8 @@ fun PdfViewerScreen(
|
||||||
val chunks = splitTextIntoChunks(textToChunk)
|
val chunks = splitTextIntoChunks(textToChunk)
|
||||||
|
|
||||||
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
||||||
?: effectivePdfUri.lastPathSegment ?: "Document"
|
?: effectivePdfUri.lastPathSegment ?: context.getString(R.string.default_document_title)
|
||||||
val pageTitle = "Page ${pageToRead + 1}"
|
val pageTitle = context.getString(R.string.pdf_page_short, pageToRead + 1)
|
||||||
|
|
||||||
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
||||||
|
|
||||||
|
|
@ -2805,8 +2809,8 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val finalError = when {
|
val finalError = when {
|
||||||
ocrAttempted -> "OCR found no text on this page."
|
ocrAttempted -> context.getString(R.string.error_no_text_on_page_after_ocr)
|
||||||
else -> "Page seems empty or text not extractable."
|
else -> context.getString(R.string.error_page_text_not_extractable)
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextPage = pageToRead + 1
|
val nextPage = pageToRead + 1
|
||||||
|
|
@ -3216,7 +3220,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.e(e, "Error loading fixed-layout document")
|
Timber.e(e, "Error loading fixed-layout document")
|
||||||
errorMessage = "Error loading document: ${e.localizedMessage}"
|
errorMessage = context.getString(R.string.error_loading_document_format, e.localizedMessage)
|
||||||
isLoadingDocument = false
|
isLoadingDocument = false
|
||||||
}
|
}
|
||||||
if (pdfDocument == null) {
|
if (pdfDocument == null) {
|
||||||
|
|
@ -3516,7 +3520,7 @@ fun PdfViewerScreen(
|
||||||
results.add(
|
results.add(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
locationInSource = match.pageIndex,
|
locationInSource = match.pageIndex,
|
||||||
locationTitle = "Page ${match.pageIndex + 1}",
|
locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1),
|
||||||
snippet = parseSnippet(match.snippet),
|
snippet = parseSnippet(match.snippet),
|
||||||
query = query,
|
query = query,
|
||||||
occurrenceIndexInLocation = occurrenceIndex,
|
occurrenceIndexInLocation = occurrenceIndex,
|
||||||
|
|
@ -3529,7 +3533,7 @@ fun PdfViewerScreen(
|
||||||
results.add(
|
results.add(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
locationInSource = match.pageIndex,
|
locationInSource = match.pageIndex,
|
||||||
locationTitle = "Page ${match.pageIndex + 1}",
|
locationTitle = context.getString(R.string.pdf_page_short, match.pageIndex + 1),
|
||||||
snippet = parseSnippet(match.snippet),
|
snippet = parseSnippet(match.snippet),
|
||||||
query = query,
|
query = query,
|
||||||
occurrenceIndexInLocation = 0,
|
occurrenceIndexInLocation = 0,
|
||||||
|
|
@ -3678,6 +3682,9 @@ fun PdfViewerScreen(
|
||||||
userHighlights = visibleUserHighlights,
|
userHighlights = visibleUserHighlights,
|
||||||
currentPage = currentPage,
|
currentPage = currentPage,
|
||||||
totalPages = totalDisplayPages,
|
totalPages = totalDisplayPages,
|
||||||
|
isTabsEnabled = isPdfTabStripVisible,
|
||||||
|
openTabs = openTabs,
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
customHighlightColors = customHighlightColors,
|
customHighlightColors = customHighlightColors,
|
||||||
onPageSelected = { targetPage ->
|
onPageSelected = { targetPage ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -3694,6 +3701,29 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onTabSelected = { tabBookId ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
currentBookId?.let { tabStateMap[it] = currentPage }
|
||||||
|
saveAllData(true).join()
|
||||||
|
viewModel.switchTab(tabBookId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTabClosed = { tabBookId ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
val isSelected = tabBookId == activeTabBookId
|
||||||
|
if (isSelected) saveAllData(true).join()
|
||||||
|
viewModel.closeTab(tabBookId)
|
||||||
|
if (isSelected && openTabs.size == 1) {
|
||||||
|
onNavigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNewTabClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
showNewTabSheet = true
|
||||||
|
}
|
||||||
|
},
|
||||||
onRenameBookmark = { bookmarkToRename, newTitle ->
|
onRenameBookmark = { bookmarkToRename, newTitle ->
|
||||||
if (newTitle.isNotBlank()) {
|
if (newTitle.isNotBlank()) {
|
||||||
val updatedBookmark = bookmarkToRename.copy(title = newTitle)
|
val updatedBookmark = bookmarkToRename.copy(title = newTitle)
|
||||||
|
|
@ -6376,7 +6406,7 @@ fun PdfViewerScreen(
|
||||||
AiHubBottomSheet(
|
AiHubBottomSheet(
|
||||||
bookTitle = bookTitle,
|
bookTitle = bookTitle,
|
||||||
currentChapterIndex = currentPageForDisplay,
|
currentChapterIndex = currentPageForDisplay,
|
||||||
chapterTitle = "Page ${currentPageForDisplay + 1}",
|
chapterTitle = stringResource(R.string.pdf_page_short, currentPageForDisplay + 1),
|
||||||
summaryCacheManager = summaryCacheManager,
|
summaryCacheManager = summaryCacheManager,
|
||||||
summarizationResult = summarizationResult,
|
summarizationResult = summarizationResult,
|
||||||
isSummarizationLoading = isSummarizationLoading,
|
isSummarizationLoading = isSummarizationLoading,
|
||||||
|
|
@ -6412,7 +6442,12 @@ fun PdfViewerScreen(
|
||||||
isSummarizationLoading = false
|
isSummarizationLoading = false
|
||||||
val finalSummary = summarizationResult?.summary
|
val finalSummary = summarizationResult?.summary
|
||||||
if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
|
if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
|
||||||
summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary)
|
summaryCacheManager.saveSummary(
|
||||||
|
bookTitle,
|
||||||
|
currentPageForDisplay,
|
||||||
|
context.getString(R.string.pdf_page_short, currentPageForDisplay + 1),
|
||||||
|
finalSummary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -6959,7 +6994,7 @@ fun PdfViewerScreen(
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { clickedLinkUrl = null },
|
onDismissRequest = { clickedLinkUrl = null },
|
||||||
title = { Text(stringResource(R.string.dialog_external_link_title)) },
|
title = { Text(stringResource(R.string.dialog_external_link_title)) },
|
||||||
text = { Text(stringResource(R.string.desc_external_link_warning)) },
|
text = { Text(stringResource(R.string.desc_external_link_warning, url)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader.tts
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -81,6 +82,7 @@ private const val PREFETCH_LOOKAHEAD = 3
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class TtsPlaybackManager(
|
class TtsPlaybackManager(
|
||||||
|
context: Context,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
|
||||||
private val onResetContext: () -> Unit,
|
private val onResetContext: () -> Unit,
|
||||||
|
|
@ -88,6 +90,7 @@ class TtsPlaybackManager(
|
||||||
private val onPlaybackSessionStopped: () -> Unit = {}
|
private val onPlaybackSessionStopped: () -> Unit = {}
|
||||||
) : MediaSession.Callback, Player.Listener {
|
) : MediaSession.Callback, Player.Listener {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
|
private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
|
||||||
|
|
@ -388,7 +391,7 @@ class TtsPlaybackManager(
|
||||||
args: Bundle // Added this parameter
|
args: Bundle // Added this parameter
|
||||||
) {
|
) {
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_no_text))
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -553,7 +556,7 @@ class TtsPlaybackManager(
|
||||||
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
||||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||||
if (firstChunk == null) {
|
if (firstChunk == null) {
|
||||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = appContext.getString(R.string.tts_error_starting_playback))
|
||||||
onPlaybackSessionStopped()
|
onPlaybackSessionStopped()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -565,7 +568,7 @@ class TtsPlaybackManager(
|
||||||
)
|
)
|
||||||
|
|
||||||
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
|
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
|
||||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||||
|
|
||||||
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
|
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
|
||||||
|
|
@ -630,7 +633,7 @@ class TtsPlaybackManager(
|
||||||
} else {
|
} else {
|
||||||
_ttsState.value = _ttsState.value.copy(
|
_ttsState.value = _ttsState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
|
||||||
)
|
)
|
||||||
onPlaybackSessionStopped()
|
onPlaybackSessionStopped()
|
||||||
}
|
}
|
||||||
|
|
@ -827,7 +830,7 @@ class TtsPlaybackManager(
|
||||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
|
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
|
||||||
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
|
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_playback, error.message.orEmpty()))
|
||||||
handleStopTts(userInitiated = true)
|
handleStopTts(userInitiated = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -854,7 +857,7 @@ class TtsPlaybackManager(
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
|
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
|
||||||
|
|
||||||
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
|
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
|
||||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||||
|
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
|
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -729,7 +729,7 @@ class TtsService : MediaSessionService() {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
|
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
|
||||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
|
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = getString(R.string.tts_error_cloud_not_configured))
|
||||||
} else {
|
} else {
|
||||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
|
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
|
||||||
liveClient.generateChunk(text, cachedFile)
|
liveClient.generateChunk(text, cachedFile)
|
||||||
|
|
@ -810,6 +810,7 @@ class TtsService : MediaSessionService() {
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
||||||
|
|
||||||
playbackManager = TtsPlaybackManager(
|
playbackManager = TtsPlaybackManager(
|
||||||
|
context = this,
|
||||||
player = player,
|
player = player,
|
||||||
generateAudioChunk = audioGenerator,
|
generateAudioChunk = audioGenerator,
|
||||||
onResetContext = { liveClient.close() },
|
onResetContext = { liveClient.close() },
|
||||||
|
|
|
||||||
11
app/src/main/res/drawable-nodpi/format_align_right.xml
Normal file
11
app/src/main/res/drawable-nodpi/format_align_right.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M120,840L120,760L840,760L840,840L120,840ZM360,680L360,600L840,600L840,680L360,680ZM120,520L120,440L840,440L840,520L120,520ZM360,360L360,280L840,280L840,360L360,360ZM120,200L120,120L840,120L840,200L120,200Z"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -642,4 +642,7 @@
|
||||||
<string name="label_highlight_color">مُظلِل</string>
|
<string name="label_highlight_color">مُظلِل</string>
|
||||||
<string name="msg_page_unavailable">الصفحة غير متوفرة</string>
|
<string name="msg_page_unavailable">الصفحة غير متوفرة</string>
|
||||||
<string name="sign_in_to_purchase_credits">يرجى تسجيل الدخول إلى حساب Google الخاص بك لشراء رصيد.</string>
|
<string name="sign_in_to_purchase_credits">يرجى تسجيل الدخول إلى حساب Google الخاص بك لشراء رصيد.</string>
|
||||||
|
<string name="language_system_default">لغة النظام</string>
|
||||||
|
<string name="language_english">English (الإنجليزية)</string>
|
||||||
|
<string name="language_spanish">Español (الإسبانية)</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -732,12 +732,15 @@
|
||||||
<string name="options_export_logs_last_lines">Protokolle exportieren (Letzte %1$d Zeilen)</string>
|
<string name="options_export_logs_last_lines">Protokolle exportieren (Letzte %1$d Zeilen)</string>
|
||||||
<string name="dialog_strict_file_filter_title">Strengen Dateifilter aktivieren</string>
|
<string name="dialog_strict_file_filter_title">Strengen Dateifilter aktivieren</string>
|
||||||
<string name="dialog_strict_file_filter_desc">Wenn Sie diese Option aktivieren, werden einige unterstützte Dateiformate wie AZW3, CB7 und FB2 je nach Dateimanager möglicherweise nicht angezeigt.\n\nSind sie sicher, dass Sie diesen Filter aktivieren möchten?</string>
|
<string name="dialog_strict_file_filter_desc">Wenn Sie diese Option aktivieren, werden einige unterstützte Dateiformate wie AZW3, CB7 und FB2 je nach Dateimanager möglicherweise nicht angezeigt.\n\nSind sie sicher, dass Sie diesen Filter aktivieren möchten?</string>
|
||||||
|
<string name="language_system_default">Systemstandard</string>
|
||||||
|
<string name="language_english">Englisch</string>
|
||||||
<string name="language_english_default">Englisch (Standard)</string>
|
<string name="language_english_default">Englisch (Standard)</string>
|
||||||
<string name="language_arabic">العربية (Arabisch)</string>
|
<string name="language_arabic">العربية (Arabisch)</string>
|
||||||
<string name="language_german">Deutsch (Deutsch)</string>
|
<string name="language_german">Deutsch (Deutsch)</string>
|
||||||
<string name="language_turkish">Türkçe (Türkisch)</string>
|
<string name="language_turkish">Türkçe (Türkisch)</string>
|
||||||
<string name="language_french">Français (Französisch)</string>
|
<string name="language_french">Français (Französisch)</string>
|
||||||
<string name="language_russian">Русский (Russisch)</string>
|
<string name="language_russian">Русский (Russisch)</string>
|
||||||
|
<string name="language_spanish">Español (Spanisch)</string>
|
||||||
<string name="app_theme_title">App-Thema</string>
|
<string name="app_theme_title">App-Thema</string>
|
||||||
<string name="app_theme_text_brightness">Text-Helligkeit</string>
|
<string name="app_theme_text_brightness">Text-Helligkeit</string>
|
||||||
<string name="app_theme_color_scheme">Farbschema</string>
|
<string name="app_theme_color_scheme">Farbschema</string>
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,54 @@
|
||||||
<resources>
|
<resources>
|
||||||
<plurals name="book_count">
|
<plurals name="book_count">
|
||||||
<item quantity="one">%1$d libro</item>
|
<item quantity="one">%1$d libro</item>
|
||||||
<item quantity="many">%1$d libros</item>
|
|
||||||
<item quantity="other">%1$d libros</item>
|
<item quantity="other">%1$d libros</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="book_word">
|
<plurals name="book_word">
|
||||||
<item quantity="one">libro</item>
|
<item quantity="one">libro</item>
|
||||||
<item quantity="many">libros</item>
|
|
||||||
<item quantity="other">libros</item>
|
<item quantity="other">libros</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="shelf_count">
|
<plurals name="shelf_count">
|
||||||
<item quantity="one">%1$d estantería</item>
|
<item quantity="one">%1$d estante</item>
|
||||||
<item quantity="many">%1$d estanterías</item>
|
<item quantity="other">%1$d estantes</item>
|
||||||
<item quantity="other">%1$d estanterías</item>
|
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="search_results_count">
|
<plurals name="search_results_count">
|
||||||
<item quantity="one">%1$d resultado encontrado</item>
|
<item quantity="one">%1$d resultado encontrado</item>
|
||||||
<item quantity="many">%1$d resultados encontrados</item>
|
|
||||||
<item quantity="other">%1$d resultados encontrados</item>
|
<item quantity="other">%1$d resultados encontrados</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="search_matches_count">
|
||||||
|
<item quantity="one">%1$d coincidencia encontrada</item>
|
||||||
|
<item quantity="other">%1$d coincidencias encontradas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="dialog_delete_permanently">
|
||||||
|
<item quantity="one">Eliminar archivo permanentemente</item>
|
||||||
|
<item quantity="other">Eliminar archivos permanentemente</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="dialog_permanently_delete_desc">
|
||||||
|
<item quantity="one">¿Quieres eliminar permanentemente %1$d archivo seleccionado de tu dispositivo? Esta acción no se puede deshacer.</item>
|
||||||
|
<item quantity="other">¿Quieres eliminar permanentemente %1$d archivos seleccionados de tu dispositivo? Esta acción no se puede deshacer.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="dialog_remove_recents_desc">
|
||||||
|
<item quantity="one">¿Quieres quitar %1$d archivo seleccionado de la lista de archivos recientes? Volverá a aparecer si lo abres de nuevo desde la biblioteca.</item>
|
||||||
|
<item quantity="other">¿Quieres quitar %1$d archivos seleccionados de la lista de archivos recientes? Volverán a aparecer si los abres de nuevo desde la biblioteca.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="dialog_remove_from_shelf_desc">
|
||||||
|
<item quantity="one">¿Seguro que quieres quitar %1$d libro del estante \'%2$s\'? El libro seguirá en tu biblioteca y aparecerá en Sin estante.</item>
|
||||||
|
<item quantity="other">¿Seguro que quieres quitar %1$d libros del estante \'%2$s\'? Los libros seguirán en tu biblioteca y aparecerán en Sin estante.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="banner_books_removed_library">
|
||||||
|
<item quantity="one">%1$d libro quitado de la biblioteca.</item>
|
||||||
|
<item quantity="other">%1$d libros quitados de la biblioteca.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="folder_count">
|
||||||
|
<item quantity="one">%1$d carpeta</item>
|
||||||
|
<item quantity="other">%1$d carpetas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="tag_count">
|
||||||
|
<item quantity="one">%1$d etiqueta</item>
|
||||||
|
<item quantity="other">%1$d etiquetas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="tts_cache_chunk_count_parenthetical">
|
||||||
|
<item quantity="one">(%1$d fragmento)</item>
|
||||||
|
<item quantity="other">(%1$d fragmentos)</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -705,12 +705,15 @@
|
||||||
<string name="options_language">Langue</string>
|
<string name="options_language">Langue</string>
|
||||||
<string name="options_export_logs_last_lines">Exporter les logs (dernières %1$d lignes)</string>
|
<string name="options_export_logs_last_lines">Exporter les logs (dernières %1$d lignes)</string>
|
||||||
<string name="dialog_strict_file_filter_desc">Si vous activez cette option, certains formats supportés comme AZW3, CB7 et FB2 pourraient ne pas apparaitre, selon votre gestionnaire de fichiers.\n\nÊtes-vous sûr de vouloir activer ce filtre ?</string>
|
<string name="dialog_strict_file_filter_desc">Si vous activez cette option, certains formats supportés comme AZW3, CB7 et FB2 pourraient ne pas apparaitre, selon votre gestionnaire de fichiers.\n\nÊtes-vous sûr de vouloir activer ce filtre ?</string>
|
||||||
|
<string name="language_system_default">Langue du système</string>
|
||||||
|
<string name="language_english">English (Anglais)</string>
|
||||||
<string name="language_english_default">English (Anglais ; par défaut)</string>
|
<string name="language_english_default">English (Anglais ; par défaut)</string>
|
||||||
<string name="language_arabic">العربية (Arabe)</string>
|
<string name="language_arabic">العربية (Arabe)</string>
|
||||||
<string name="language_german">Deutsch (Allemand)</string>
|
<string name="language_german">Deutsch (Allemand)</string>
|
||||||
<string name="language_turkish">Türkçe (Turque)</string>
|
<string name="language_turkish">Türkçe (Turque)</string>
|
||||||
<string name="language_french">Français</string>
|
<string name="language_french">Français</string>
|
||||||
<string name="language_russian">Русский (Russe)</string>
|
<string name="language_russian">Русский (Russe)</string>
|
||||||
|
<string name="language_spanish">Español (Espagnol)</string>
|
||||||
<string name="app_theme_contrast">Contraste</string>
|
<string name="app_theme_contrast">Contraste</string>
|
||||||
<string name="app_theme_text_brightness">Luminosité du texte</string>
|
<string name="app_theme_text_brightness">Luminosité du texte</string>
|
||||||
<string name="app_theme_preset_ocean">Océan</string>
|
<string name="app_theme_preset_ocean">Océan</string>
|
||||||
|
|
|
||||||
|
|
@ -707,12 +707,15 @@
|
||||||
<string name="options_export_logs_last_lines">Экспорт %1$d последних строк журнала</string>
|
<string name="options_export_logs_last_lines">Экспорт %1$d последних строк журнала</string>
|
||||||
<string name="dialog_strict_file_filter_title">Строгий фильтр файлов</string>
|
<string name="dialog_strict_file_filter_title">Строгий фильтр файлов</string>
|
||||||
<string name="dialog_strict_file_filter_desc">Если вы включите эту функцию, некоторые поддерживаемые типы файлов, такие как AZW3, CB7 и FB2, могут не отображаться в зависимости от используемого вами файлового менеджера.\n\nВы уверены, что хотите включить этот фильтр?</string>
|
<string name="dialog_strict_file_filter_desc">Если вы включите эту функцию, некоторые поддерживаемые типы файлов, такие как AZW3, CB7 и FB2, могут не отображаться в зависимости от используемого вами файлового менеджера.\n\nВы уверены, что хотите включить этот фильтр?</string>
|
||||||
|
<string name="language_system_default">Системный язык</string>
|
||||||
|
<string name="language_english">Английский</string>
|
||||||
<string name="language_english_default">Английский (по умолчанию)</string>
|
<string name="language_english_default">Английский (по умолчанию)</string>
|
||||||
<string name="language_arabic">Арабский</string>
|
<string name="language_arabic">Арабский</string>
|
||||||
<string name="language_german">Немецкий</string>
|
<string name="language_german">Немецкий</string>
|
||||||
<string name="language_turkish">Турецкий</string>
|
<string name="language_turkish">Турецкий</string>
|
||||||
<string name="language_french">Французский</string>
|
<string name="language_french">Французский</string>
|
||||||
<string name="language_russian">Русский</string>
|
<string name="language_russian">Русский</string>
|
||||||
|
<string name="language_spanish">Испанский</string>
|
||||||
<string name="app_theme_title">Тема приложения</string>
|
<string name="app_theme_title">Тема приложения</string>
|
||||||
<string name="app_theme_appearance">Внешний вид</string>
|
<string name="app_theme_appearance">Внешний вид</string>
|
||||||
<string name="app_theme_contrast">Контрастность</string>
|
<string name="app_theme_contrast">Контрастность</string>
|
||||||
|
|
|
||||||
|
|
@ -707,12 +707,15 @@
|
||||||
<string name="options_export_logs_last_lines">Günlükleri Dışarı Aktar (Son %1$d satır)</string>
|
<string name="options_export_logs_last_lines">Günlükleri Dışarı Aktar (Son %1$d satır)</string>
|
||||||
<string name="dialog_strict_file_filter_title">Katı Dosya Filtresini Aç</string>
|
<string name="dialog_strict_file_filter_title">Katı Dosya Filtresini Aç</string>
|
||||||
<string name="dialog_strict_file_filter_desc">Bunu etkinleştirirseniz, dosya yöneticinize bağlı olarak AZW3, CB7 ve FB2 gibi bazı desteklenen dosya türleri görünmeyebilir.\n\nBu filtreyi etkinleştirmek istediğinizden emin misiniz?</string>
|
<string name="dialog_strict_file_filter_desc">Bunu etkinleştirirseniz, dosya yöneticinize bağlı olarak AZW3, CB7 ve FB2 gibi bazı desteklenen dosya türleri görünmeyebilir.\n\nBu filtreyi etkinleştirmek istediğinizden emin misiniz?</string>
|
||||||
|
<string name="language_system_default">Sistem varsayılanı</string>
|
||||||
|
<string name="language_english">İngilizce</string>
|
||||||
<string name="language_english_default">İngilizce (Varsayılan)</string>
|
<string name="language_english_default">İngilizce (Varsayılan)</string>
|
||||||
<string name="language_arabic">العربية (Arapça)</string>
|
<string name="language_arabic">العربية (Arapça)</string>
|
||||||
<string name="language_german">Deutsch (Almanca)</string>
|
<string name="language_german">Deutsch (Almanca)</string>
|
||||||
<string name="language_turkish">Türkçe</string>
|
<string name="language_turkish">Türkçe</string>
|
||||||
<string name="language_french">Français (Fransızca)</string>
|
<string name="language_french">Français (Fransızca)</string>
|
||||||
<string name="language_russian">Русский (Rusça)</string>
|
<string name="language_russian">Русский (Rusça)</string>
|
||||||
|
<string name="language_spanish">Español (İspanyolca)</string>
|
||||||
<string name="app_theme_title">Uygulama Teması</string>
|
<string name="app_theme_title">Uygulama Teması</string>
|
||||||
<string name="app_theme_appearance">Görünüm</string>
|
<string name="app_theme_appearance">Görünüm</string>
|
||||||
<string name="app_theme_contrast">Karşıtlık</string>
|
<string name="app_theme_contrast">Karşıtlık</string>
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,7 @@
|
||||||
|
|
||||||
<!-- EpubReaderDrawer.kt -->
|
<!-- EpubReaderDrawer.kt -->
|
||||||
<string name="tab_chapters">Chapters</string>
|
<string name="tab_chapters">Chapters</string>
|
||||||
|
<string name="tab_tabs">Tabs</string>
|
||||||
<string name="tab_bookmarks">Bookmarks</string>
|
<string name="tab_bookmarks">Bookmarks</string>
|
||||||
<string name="tab_highlights">Highlights</string>
|
<string name="tab_highlights">Highlights</string>
|
||||||
<string name="tab_pages">Pages</string>
|
<string name="tab_pages">Pages</string>
|
||||||
|
|
@ -1108,12 +1109,15 @@
|
||||||
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
|
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
|
||||||
|
|
||||||
<!-- App language picker. Language names are shown in their own language followed by an English hint. -->
|
<!-- App language picker. Language names are shown in their own language followed by an English hint. -->
|
||||||
|
<string name="language_system_default">System default</string>
|
||||||
|
<string name="language_english">English</string>
|
||||||
<string name="language_english_default">English (Default)</string>
|
<string name="language_english_default">English (Default)</string>
|
||||||
<string name="language_arabic">العربية (Arabic)</string>
|
<string name="language_arabic">العربية (Arabic)</string>
|
||||||
<string name="language_german">Deutsch (German)</string>
|
<string name="language_german">Deutsch (German)</string>
|
||||||
<string name="language_turkish">Türkçe (Turkish)</string>
|
<string name="language_turkish">Türkçe (Turkish)</string>
|
||||||
<string name="language_french">Français (French)</string>
|
<string name="language_french">Français (French)</string>
|
||||||
<string name="language_russian">Русский (Russian)</string>
|
<string name="language_russian">Русский (Russian)</string>
|
||||||
|
<string name="language_spanish">Español (Spanish)</string>
|
||||||
|
|
||||||
<!-- App-wide theme controls in HomeScreen.kt. -->
|
<!-- App-wide theme controls in HomeScreen.kt. -->
|
||||||
<string name="app_theme_title">App Theme</string>
|
<string name="app_theme_title">App Theme</string>
|
||||||
|
|
@ -1326,4 +1330,223 @@
|
||||||
<string name="options_screen_capture_protection">Screen capture protection</string>
|
<string name="options_screen_capture_protection">Screen capture protection</string>
|
||||||
<string name="banner_screen_capture_protection_on">Screen capture protection is on</string>
|
<string name="banner_screen_capture_protection_on">Screen capture protection is on</string>
|
||||||
<string name="banner_screen_capture_protection_off">Screen capture protection is off</string>
|
<string name="banner_screen_capture_protection_off">Screen capture protection is off</string>
|
||||||
|
|
||||||
|
<!-- Android-only hardcoded UI string cleanup. -->
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="action_edit">Edit</string>
|
||||||
|
<string name="action_restore">Restore</string>
|
||||||
|
<!-- %1$s = the selected option label. -->
|
||||||
|
<string name="option_selected_format">%1$s selected</string>
|
||||||
|
<string name="reader_defaults">Reader defaults</string>
|
||||||
|
<!-- Debug-only placeholder, not shown in normal user flows. -->
|
||||||
|
<string name="debug_actions_existing_menus" translatable="false">Debug actions remain in their existing menus.</string>
|
||||||
|
<!-- PDF and OCR are technical feature names; keep the acronyms as-is. -->
|
||||||
|
<string name="pdf_specific_settings_existing_reader">PDF-specific OCR, annotation, and tool settings remain in the PDF reader.</string>
|
||||||
|
<!-- Provider/product name. Do not translate. -->
|
||||||
|
<string name="provider_gemini" translatable="false">Gemini</string>
|
||||||
|
<!-- Provider/product name. Do not translate. -->
|
||||||
|
<string name="provider_groq" translatable="false">Groq</string>
|
||||||
|
<!-- AI = Artificial Intelligence. Keep the acronym "AI" as-is. -->
|
||||||
|
<string name="ai_definition_title">AI Definition</string>
|
||||||
|
<!-- %1$d = 1-indexed chapter number. -->
|
||||||
|
<string name="chapter_number_format">Chapter %1$d</string>
|
||||||
|
<string name="location_generic">Location</string>
|
||||||
|
<string name="custom_font_fallback">Custom Font</string>
|
||||||
|
<!-- Raw status/detail format only. %1$d = response code; %2$s = server/system detail. -->
|
||||||
|
<string name="error_response_code_with_detail" translatable="false">%1$d. %2$s</string>
|
||||||
|
<!-- %1$s = error detail from the system. -->
|
||||||
|
<string name="error_occurred_format">An error occurred: %1$s</string>
|
||||||
|
<!-- %1$s = error detail from the document loader. -->
|
||||||
|
<string name="error_loading_document_format">Error loading document: %1$s</string>
|
||||||
|
<!-- OCR = Optical Character Recognition. Keep the acronym as-is. -->
|
||||||
|
<string name="error_no_text_on_page_after_ocr">OCR found no text on this page.</string>
|
||||||
|
<string name="error_page_text_not_extractable">Page seems empty or text not extractable.</string>
|
||||||
|
<!-- PDF = Portable Document Format. Keep the acronym as-is. -->
|
||||||
|
<string name="pdf_error_blank_page_summary">Cannot summarize a blank page.</string>
|
||||||
|
<!-- PDF = Portable Document Format. Keep the acronym as-is. -->
|
||||||
|
<string name="pdf_error_document_not_loaded">Document not loaded.</string>
|
||||||
|
<!-- AI = Artificial Intelligence; OSS = Open Source Software. Keep both acronyms as-is. -->
|
||||||
|
<string name="ai_error_offline_oss">AI features are unavailable in the offline OSS build.</string>
|
||||||
|
<string name="ai_error_blocked_safety">Blocked for safety reasons.</string>
|
||||||
|
<!-- AI = Artificial Intelligence. %1$s = feature name such as "Summaries". -->
|
||||||
|
<string name="ai_error_choose_model">Choose a model for %1$s in AI key and model settings.</string>
|
||||||
|
<!-- API = Application Programming Interface. %1$s = provider name such as Gemini or Groq. -->
|
||||||
|
<string name="ai_error_add_provider_key">Add a %1$s API key in AI key and model settings.</string>
|
||||||
|
<!-- AI = Artificial Intelligence. -->
|
||||||
|
<string name="ai_error_provider_empty_response">The AI provider returned an empty response.</string>
|
||||||
|
<!-- AI = Artificial Intelligence. %1$d = HTTP/status code; %2$s = raw provider error body. -->
|
||||||
|
<string name="ai_error_provider_error">AI provider error: %1$d. %2$s</string>
|
||||||
|
<!-- Gemini and Groq are provider names. PDF = file format; keep all as-is. -->
|
||||||
|
<string name="ai_error_gemini_required_for_image_summary">This summary needs a Gemini model because the selected Groq models do not support PDF/image input.</string>
|
||||||
|
<string name="feedback_error_sign_in_required">You must be signed in to use feedback.</string>
|
||||||
|
<string name="feedback_error_sign_in_submit">You must be signed in to submit feedback.</string>
|
||||||
|
<string name="feedback_error_ticket_image_limit">Max 3 images allowed for tickets.</string>
|
||||||
|
<string name="feedback_error_message_image_limit">Max 5 images allowed per message.</string>
|
||||||
|
<!-- 5MB = five megabytes; keep the unit compact. -->
|
||||||
|
<string name="feedback_error_images_size_limit">One or more images exceed the 5MB limit.</string>
|
||||||
|
<!-- %1$s = error detail from the feedback backend. -->
|
||||||
|
<string name="feedback_error_create_ticket">Failed to create ticket: %1$s</string>
|
||||||
|
<!-- %1$s = error detail from the feedback backend. -->
|
||||||
|
<string name="feedback_error_send">Failed to send: %1$s</string>
|
||||||
|
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = error detail. -->
|
||||||
|
<string name="opds_error_load_feed">Failed to load feed: %1$s</string>
|
||||||
|
<string name="opds_error_empty_response">Empty body</string>
|
||||||
|
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = server message. -->
|
||||||
|
<string name="opds_error_download_failed">Download failed: %1$s</string>
|
||||||
|
<!-- OPDS = Open Publication Distribution System; keep the acronym as-is. %1$s = error detail. -->
|
||||||
|
<string name="opds_error_download_error">Download error: %1$s</string>
|
||||||
|
<!-- %1$s = billing debug message from Google Play Billing. -->
|
||||||
|
<string name="billing_error_purchase_failed">Purchase failed: %1$s</string>
|
||||||
|
<string name="billing_error_connect">Could not connect to billing service.</string>
|
||||||
|
<string name="billing_error_products_not_found">Products not found.</string>
|
||||||
|
<string name="billing_error_query_products_failed">Failed to query products</string>
|
||||||
|
<!-- OSS = Open Source Software. Keep the acronym as-is. -->
|
||||||
|
<string name="billing_error_not_available_oss">Not available in Open Source version</string>
|
||||||
|
<!-- TTS = Text-to-Speech. -->
|
||||||
|
<string name="tts_error_no_text">No text to read.</string>
|
||||||
|
<string name="tts_error_starting_playback">Error starting playback.</string>
|
||||||
|
<string name="tts_error_load_audio">Failed to load audio.</string>
|
||||||
|
<!-- TTS = Text-to-Speech. %1$s = playback error detail. -->
|
||||||
|
<string name="tts_error_playback">Playback error: %1$s</string>
|
||||||
|
<!-- TTS = Text-to-Speech. -->
|
||||||
|
<string name="tts_error_cloud_not_configured">Cloud TTS is not configured.</string>
|
||||||
|
<!-- Fallback media title when the source book title is unavailable. -->
|
||||||
|
<string name="tts_unknown_book">Unknown Book</string>
|
||||||
|
|
||||||
|
<!-- AI key/model settings. AI = Artificial Intelligence. -->
|
||||||
|
<string name="ai_settings_title">AI keys and models</string>
|
||||||
|
<string name="ai_settings_saved_keys">Saved keys</string>
|
||||||
|
<string name="ai_settings_add_or_replace_key">Add or replace key</string>
|
||||||
|
<string name="label_provider">Provider</string>
|
||||||
|
<!-- API = Application Programming Interface. Keep the acronym as-is. -->
|
||||||
|
<string name="label_api_key">API key</string>
|
||||||
|
<string name="ai_settings_save_key">Save key</string>
|
||||||
|
<string name="ai_settings_use_one_model">Use one model for all features</string>
|
||||||
|
<string name="ai_settings_use_one_model_desc">When off, each reader AI feature uses its own selected model.</string>
|
||||||
|
<!-- AI = Artificial Intelligence. -->
|
||||||
|
<string name="ai_settings_all_features">All AI features</string>
|
||||||
|
<string name="ai_settings_all_features_desc">Smart dictionary, summaries, and recaps all use this model.</string>
|
||||||
|
<string name="ai_settings_smart_dictionary">Smart dictionary</string>
|
||||||
|
<string name="ai_settings_smart_dictionary_desc">Used when defining selected words or phrases.</string>
|
||||||
|
<string name="ai_settings_summaries">Summaries</string>
|
||||||
|
<!-- EPUB and PDF are file format names. Gemini is a provider name. Keep all as-is. -->
|
||||||
|
<string name="ai_settings_summaries_desc">Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.</string>
|
||||||
|
<string name="ai_settings_recaps">Recaps</string>
|
||||||
|
<string name="ai_settings_recaps_desc">Used for story recap generation.</string>
|
||||||
|
<!-- TTS = Text-to-Speech. Gemini is a provider name. %1$s = exact model identifier; do not translate the inserted value. -->
|
||||||
|
<string name="ai_settings_cloud_tts_desc">Uses the saved Gemini key. Only %1$s is supported for now.</string>
|
||||||
|
<!-- %1$s = provider name, such as Gemini or Groq. -->
|
||||||
|
<string name="dialog_save_provider_key">Save %1$s key?</string>
|
||||||
|
<string name="dialog_save_key_desc">After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.</string>
|
||||||
|
<!-- %1$s = provider name, such as Gemini or Groq. -->
|
||||||
|
<string name="dialog_delete_provider_key">Delete %1$s key?</string>
|
||||||
|
<string name="dialog_delete_key_desc">Features using this provider will stop working until a new key is saved.</string>
|
||||||
|
<string name="ai_settings_no_key_saved">No key saved</string>
|
||||||
|
<!-- %1$s = provider display name. -->
|
||||||
|
<string name="content_desc_delete_provider_key">Delete %1$s key</string>
|
||||||
|
<string name="label_model">Model</string>
|
||||||
|
<string name="ai_settings_no_model_selected">No model selected</string>
|
||||||
|
<string name="options_show_ai_in_reader">Show AI in reader</string>
|
||||||
|
<string name="options_hide_ai_in_reader">Hide AI in reader</string>
|
||||||
|
|
||||||
|
<!-- Theme and texture editor. -->
|
||||||
|
<string name="theme_solid_colors">Solid Colors</string>
|
||||||
|
<string name="theme_textured">Textured</string>
|
||||||
|
<string name="theme_custom_solid_default">Custom Solid</string>
|
||||||
|
<string name="theme_custom_textured_default">Custom Textured</string>
|
||||||
|
<string name="theme_select_custom_texture">Select Custom Texture</string>
|
||||||
|
<string name="app_theme_text_brightness_light">Text Brightness (Light)</string>
|
||||||
|
<string name="app_theme_text_brightness_dark">Text Brightness (Dark)</string>
|
||||||
|
<string name="label_default">Default</string>
|
||||||
|
<string name="label_left">Left</string>
|
||||||
|
<string name="label_right">Right</string>
|
||||||
|
<string name="label_justify">Justify</string>
|
||||||
|
<string name="label_always_show">Always Show</string>
|
||||||
|
<string name="label_sync_with_menus">Sync with Menus</string>
|
||||||
|
<string name="label_always_hide">Always Hide</string>
|
||||||
|
<string name="label_top">Top</string>
|
||||||
|
<string name="label_bottom">Bottom</string>
|
||||||
|
|
||||||
|
<!-- File metadata dialog. -->
|
||||||
|
<string name="dialog_restore_original_metadata">Restore original metadata?</string>
|
||||||
|
<!-- EPUB is a file format name; keep the acronym as-is. -->
|
||||||
|
<string name="dialog_restore_original_metadata_desc">This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change.</string>
|
||||||
|
<!-- EPUB is a file format name; keep the acronym as-is. -->
|
||||||
|
<string name="metadata_provenance_epub_edited">EPUB metadata edited</string>
|
||||||
|
<!-- EPUB is a file format name; keep the acronym as-is. -->
|
||||||
|
<string name="metadata_provenance_from_epub">Metadata from EPUB file</string>
|
||||||
|
<string name="metadata_provenance_display_name_changed">Display name changed in app</string>
|
||||||
|
<string name="metadata_provenance_from_file">Metadata from file</string>
|
||||||
|
<string name="section_metadata">Metadata</string>
|
||||||
|
<string name="section_file">File</string>
|
||||||
|
<string name="label_title">Title</string>
|
||||||
|
<string name="label_series">Series</string>
|
||||||
|
<string name="label_reading">Reading</string>
|
||||||
|
<string name="label_file_name_simple">File name</string>
|
||||||
|
<string name="label_modified">Modified</string>
|
||||||
|
<string name="label_summary">Summary</string>
|
||||||
|
<string name="label_library_tags">Library tags</string>
|
||||||
|
<string name="label_editable_metadata">Editable metadata</string>
|
||||||
|
<string name="label_display_name">Display name</string>
|
||||||
|
<string name="label_name_shown_in_reader">Name shown in Reader</string>
|
||||||
|
<!-- %1$s = original file name. -->
|
||||||
|
<string name="original_file_format">Original file: %1$s</string>
|
||||||
|
|
||||||
|
<!-- Toolbar customization. -->
|
||||||
|
<string name="toolbar_top_bar">Top Bar</string>
|
||||||
|
<string name="toolbar_bottom_bar">Bottom Bar</string>
|
||||||
|
<string name="toolbar_hidden_tools">Hidden Tools</string>
|
||||||
|
<string name="toolbar_more_menu">More menu</string>
|
||||||
|
<string name="toolbar_hidden_tools_menu">Hidden tools</string>
|
||||||
|
<string name="toolbar_drop_tools_here">Drop tools here</string>
|
||||||
|
<string name="content_desc_drag_to_reorder">Drag to reorder</string>
|
||||||
|
<string name="tool_external_apps">External Apps</string>
|
||||||
|
<string name="tool_navigation_slider">Navigation Slider</string>
|
||||||
|
<string name="tool_sidebar">Sidebar</string>
|
||||||
|
<string name="tool_highlight_selectable_text">Highlight selectable text</string>
|
||||||
|
<string name="tool_edit_mode">Edit Mode</string>
|
||||||
|
<!-- TTS = Text-to-Speech. Keep the acronym as-is. -->
|
||||||
|
<string name="tool_tts_controls">TTS Controls</string>
|
||||||
|
<string name="tool_reading_mode">Reading Mode</string>
|
||||||
|
<string name="tool_page_management">Page Management</string>
|
||||||
|
<!-- "Text View" is the PDF reflow feature name. -->
|
||||||
|
<string name="tool_text_view_reflow">Text View (Reflow)</string>
|
||||||
|
|
||||||
|
<!-- TTS word replacement sheet. TTS = Text-to-Speech. -->
|
||||||
|
<string name="tts_replacements_current_book">Current book</string>
|
||||||
|
<string name="tts_replacements_tab_global">Global</string>
|
||||||
|
<string name="tts_replacements_tab_this_book">This book</string>
|
||||||
|
<string name="tts_replacements_enable">Enable replacements</string>
|
||||||
|
<string name="tts_replacements_enable_desc">Rules here apply to every book unless disabled for a specific title.</string>
|
||||||
|
<string name="tts_replacements_add_rule">Add rule</string>
|
||||||
|
<string name="tts_replacements_add_book_rule">Add book rule</string>
|
||||||
|
<string name="tts_replacements_empty_global">No global replacement rules yet.</string>
|
||||||
|
<string name="tts_replacements_empty_book">No book-specific rules yet.</string>
|
||||||
|
<string name="tts_replacements_use_global_here">Use global rules here</string>
|
||||||
|
<string name="tts_replacements_use_global_here_desc">Turn this off when a book needs its own pronunciation choices.</string>
|
||||||
|
<string name="tts_replacements_enable_book_rules">Enable book rules</string>
|
||||||
|
<string name="tts_replacements_enable_book_rules_desc">Local rules run after global rules.</string>
|
||||||
|
<string name="tts_replacements_inherited_global_rules">Inherited global rules</string>
|
||||||
|
<string name="tts_replacements_no_global_rules">No global rules to inherit.</string>
|
||||||
|
<string name="tts_replacements_allowed_in_book">Allowed in this book</string>
|
||||||
|
<string name="tts_replacements_disabled_for_book">Disabled for this book</string>
|
||||||
|
<string name="tts_replacements_suggestions">Suggestions</string>
|
||||||
|
<!-- English sample input for replacement previews; keep abbreviations as written so built-in examples behave consistently. -->
|
||||||
|
<string name="tts_replacements_preview_default" translatable="false">Dr. Smith met NASA at 5 p.m.</string>
|
||||||
|
<string name="tts_replacements_new_replacement">New replacement</string>
|
||||||
|
<string name="tts_replacements_edit_replacement">Edit replacement</string>
|
||||||
|
<string name="tts_replacements_label_replace">Replace</string>
|
||||||
|
<string name="tts_replacements_label_speak_as">Speak as</string>
|
||||||
|
<string name="tts_replacements_chip_enabled">Enabled</string>
|
||||||
|
<!-- Regex is a technical term for regular expression; keep as-is. -->
|
||||||
|
<string name="tts_replacements_chip_regex" translatable="false">Regex</string>
|
||||||
|
<string name="tts_replacements_chip_whole_word">Whole word</string>
|
||||||
|
<string name="tts_replacements_chip_match_case">Match case</string>
|
||||||
|
<string name="tts_replacements_label_preview_input">Preview input</string>
|
||||||
|
<string name="tts_replacements_rules">Rules</string>
|
||||||
|
<!-- Lowercase because it is inserted into summaries like "word -> silence". -->
|
||||||
|
<string name="tts_replacements_silence">silence</string>
|
||||||
|
<string name="tts_replacements_plain_text">Plain text</string>
|
||||||
|
<!-- Lowercase because it is joined into a comma-separated option summary. -->
|
||||||
|
<string name="tts_replacements_case_sensitive">case-sensitive</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="ar"/>
|
<locale android:name="ar"/>
|
||||||
<locale android:name="de"/>
|
<locale android:name="de"/>
|
||||||
|
<locale android:name="tr"/>
|
||||||
<locale android:name="fr"/>
|
<locale android:name="fr"/>
|
||||||
<locale android:name="ru"/>
|
<locale android:name="ru"/>
|
||||||
<locale android:name="tr"/>
|
<locale android:name="es"/>
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class BillingClientWrapper(
|
||||||
private val externalScope: CoroutineScope,
|
private val externalScope: CoroutineScope,
|
||||||
private val onPurchaseVerified: (PurchaseEntity) -> Unit
|
private val onPurchaseVerified: (PurchaseEntity) -> Unit
|
||||||
) {
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
private val _proUpgradeState = MutableStateFlow(ProUpgradeState())
|
private val _proUpgradeState = MutableStateFlow(ProUpgradeState())
|
||||||
val proUpgradeState = _proUpgradeState.asStateFlow()
|
val proUpgradeState = _proUpgradeState.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ class BillingClientWrapper(
|
||||||
productId: String = PRO_LIFETIME_PRODUCT_ID,
|
productId: String = PRO_LIFETIME_PRODUCT_ID,
|
||||||
obfuscatedAccountId: String? = null
|
obfuscatedAccountId: String? = null
|
||||||
) {
|
) {
|
||||||
_proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
|
_proUpgradeState.value = _proUpgradeState.value.copy(error = appContext.getString(R.string.billing_error_not_available_oss))
|
||||||
}
|
}
|
||||||
fun consumePurchase(purchaseToken: String) {}
|
fun consumePurchase(purchaseToken: String) {}
|
||||||
|
|
||||||
|
|
|
||||||
60
app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt
Normal file
60
app/src/test/java/com/aryan/reader/AppLanguageOptionsTest.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppLanguageOptionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `supported app languages include Spanish`() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("en", "ar", "de", "tr", "fr", "ru", "es"),
|
||||||
|
supportedAppLanguageOptions.mapNotNull { it.tag }
|
||||||
|
)
|
||||||
|
assertEquals(R.string.language_spanish, supportedAppLanguageOptions.last().labelRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `app language selection defaults to system before explicit overrides`() {
|
||||||
|
assertEquals(null, appLanguageSelectionOptions.first().tag)
|
||||||
|
assertEquals(R.string.language_system_default, appLanguageSelectionOptions.first().labelRes)
|
||||||
|
assertEquals(supportedAppLanguageOptions, appLanguageSelectionOptions.drop(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `supported app language tags are unique`() {
|
||||||
|
val tags = supportedAppLanguageOptions.mapNotNull { it.tag }
|
||||||
|
|
||||||
|
assertEquals(tags.distinct(), tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `supported app languages match Android locale config`() {
|
||||||
|
assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLocaleConfigTags(): List<String> {
|
||||||
|
val localeConfig = listOf(
|
||||||
|
File("src/main/res/xml/locales_config.xml"),
|
||||||
|
File("app/src/main/res/xml/locales_config.xml")
|
||||||
|
).first { it.isFile }
|
||||||
|
val document = DocumentBuilderFactory.newInstance()
|
||||||
|
.apply { isNamespaceAware = true }
|
||||||
|
.newDocumentBuilder()
|
||||||
|
.parse(localeConfig)
|
||||||
|
val localeNodes = document.getElementsByTagName("locale")
|
||||||
|
|
||||||
|
return buildList {
|
||||||
|
for (index in 0 until localeNodes.length) {
|
||||||
|
add(
|
||||||
|
localeNodes.item(index)
|
||||||
|
.attributes
|
||||||
|
.getNamedItemNS("http://schemas.android.com/apk/res/android", "name")
|
||||||
|
.nodeValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -198,4 +198,29 @@ class EpubReaderBridgeAndControlsTest {
|
||||||
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
|
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
|
||||||
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
|
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reader toolbar reset defaults match first-run toolbar defaults`() {
|
||||||
|
assertEquals(setOf(ReaderTool.SCREEN_ORIENTATION.name), defaultReaderHiddenTools())
|
||||||
|
assertEquals(ReaderTool.entries.toList(), defaultReaderToolOrder())
|
||||||
|
assertEquals(
|
||||||
|
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
|
||||||
|
defaultReaderBottomTools()
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultItems = buildReaderToolbarItems(
|
||||||
|
hiddenTools = defaultReaderHiddenTools(),
|
||||||
|
toolOrder = defaultReaderToolOrder(),
|
||||||
|
bottomTools = defaultReaderBottomTools()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ToolbarSection.HIDDEN,
|
||||||
|
defaultItems.single { it.tool == ReaderTool.SCREEN_ORIENTATION }.section
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ToolbarSection.BOTTOM,
|
||||||
|
defaultItems.single { it.tool == ReaderTool.SLIDER }.section
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
|
||||||
verticalMargin = 0.4f,
|
verticalMargin = 0.4f,
|
||||||
fontFamily = ReaderFont.LORA,
|
fontFamily = ReaderFont.LORA,
|
||||||
customFontPath = null,
|
customFontPath = null,
|
||||||
textAlign = ReaderTextAlign.JUSTIFY
|
textAlign = ReaderTextAlign.RIGHT
|
||||||
)
|
)
|
||||||
saveLocalReaderSettings(
|
saveLocalReaderSettings(
|
||||||
context = context,
|
context = context,
|
||||||
|
|
@ -78,7 +78,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
|
||||||
|
|
||||||
assertEquals(1.4f, global.fontSize, 0.0001f)
|
assertEquals(1.4f, global.fontSize, 0.0001f)
|
||||||
assertEquals(ReaderFont.LORA, global.font)
|
assertEquals(ReaderFont.LORA, global.font)
|
||||||
assertEquals(ReaderTextAlign.JUSTIFY, global.textAlign)
|
assertEquals(ReaderTextAlign.RIGHT, global.textAlign)
|
||||||
assertNull(global.customPath)
|
assertNull(global.customPath)
|
||||||
assertEquals(0.9f, local.fontSize, 0.0001f)
|
assertEquals(0.9f, local.fontSize, 0.0001f)
|
||||||
assertEquals(1.1f, local.lineHeight, 0.0001f)
|
assertEquals(1.1f, local.lineHeight, 0.0001f)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,35 @@ class PaginatedHighlightMappingTest {
|
||||||
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paginated page highlights are scoped to page chapter`() {
|
||||||
|
val chapterFourHighlight = highlight(
|
||||||
|
cfi = "/4/10:11|/4/12:79",
|
||||||
|
text = "Original chapter text",
|
||||||
|
chapterIndex = 4
|
||||||
|
)
|
||||||
|
val chapterFiveHighlight = highlight(
|
||||||
|
cfi = "/4/10:11|/4/12:79",
|
||||||
|
text = "Different chapter text",
|
||||||
|
chapterIndex = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(chapterFiveHighlight),
|
||||||
|
highlightsForPaginatedPage(
|
||||||
|
pageChapterIndex = 5,
|
||||||
|
userHighlights = listOf(chapterFourHighlight, chapterFiveHighlight)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
emptyList<UserHighlight>(),
|
||||||
|
highlightsForPaginatedPage(
|
||||||
|
pageChapterIndex = null,
|
||||||
|
userHighlights = listOf(chapterFourHighlight)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun paragraph(
|
private fun paragraph(
|
||||||
text: String,
|
text: String,
|
||||||
cfi: String,
|
cfi: String,
|
||||||
|
|
@ -70,14 +99,15 @@ class PaginatedHighlightMappingTest {
|
||||||
|
|
||||||
private fun highlight(
|
private fun highlight(
|
||||||
cfi: String,
|
cfi: String,
|
||||||
text: String
|
text: String,
|
||||||
|
chapterIndex: Int = 0
|
||||||
): UserHighlight {
|
): UserHighlight {
|
||||||
return UserHighlight(
|
return UserHighlight(
|
||||||
id = "highlight",
|
id = "highlight",
|
||||||
cfi = cfi,
|
cfi = cfi,
|
||||||
text = text,
|
text = text,
|
||||||
color = HighlightColor.YELLOW,
|
color = HighlightColor.YELLOW,
|
||||||
chapterIndex = 0
|
chapterIndex = chapterIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,4 +160,36 @@ class PdfReaderSettingsAndSharedModelsTest {
|
||||||
assertTrue(width >= 1)
|
assertTrue(width >= 1)
|
||||||
assertTrue(height >= 1)
|
assertTrue(height >= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pdf toolbar reset defaults match first-run toolbar defaults`() {
|
||||||
|
assertEquals(
|
||||||
|
setOf(PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name),
|
||||||
|
defaultPdfHiddenTools()
|
||||||
|
)
|
||||||
|
assertEquals(PdfReaderTool.entries.toList(), defaultPdfToolOrder())
|
||||||
|
assertEquals(
|
||||||
|
PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
|
||||||
|
defaultPdfBottomTools()
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultItems = buildPdfToolbarItems(
|
||||||
|
hiddenTools = defaultPdfHiddenTools(),
|
||||||
|
toolOrder = defaultPdfToolOrder(),
|
||||||
|
bottomTools = defaultPdfBottomTools()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
PdfToolbarSection.HIDDEN,
|
||||||
|
defaultItems.single { it.tool == PdfReaderTool.SCREEN_ORIENTATION }.section
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
PdfToolbarSection.HIDDEN,
|
||||||
|
defaultItems.single { it.tool == PdfReaderTool.HIGHLIGHT_ALL }.section
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
PdfToolbarSection.BOTTOM,
|
||||||
|
defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import com.aryan.reader.shared.AppContrastOption
|
||||||
|
import com.aryan.reader.shared.AppThemeMode
|
||||||
|
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||||
|
import com.aryan.reader.shared.ui.SharedAppTheme
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.GraphicsDevice
|
||||||
|
import java.awt.KeyboardFocusManager
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
|
||||||
|
|
||||||
|
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
|
||||||
|
configureComposeSwingInterop()
|
||||||
|
application {
|
||||||
|
val windowDefaults = remember { epistemeDesktopWindowDefaults() }
|
||||||
|
val windowStateStore = remember { DesktopWindowStateStore() }
|
||||||
|
val restoredWindowState = remember { windowStateStore.load() }
|
||||||
|
val windowState = rememberWindowState(
|
||||||
|
placement = restoredWindowState?.toWindowPlacement()
|
||||||
|
?: DesktopWindowStateSnapshot.default().toWindowPlacement(),
|
||||||
|
position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center),
|
||||||
|
size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize
|
||||||
|
)
|
||||||
|
var readerFullscreen by remember { mutableStateOf(false) }
|
||||||
|
DesktopWindowStatePersistenceEffect(
|
||||||
|
windowState = windowState,
|
||||||
|
store = windowStateStore,
|
||||||
|
enabled = !readerFullscreen
|
||||||
|
)
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
title = windowDefaults.title,
|
||||||
|
state = windowState,
|
||||||
|
icon = painterResource(windowDefaults.iconResourcePath)
|
||||||
|
) {
|
||||||
|
DisposableEffect(window, windowDefaults.minimumSize) {
|
||||||
|
window.minimumSize = windowDefaults.minimumSize
|
||||||
|
onDispose {
|
||||||
|
startupSplash?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EpistemeDesktopStartupGate(
|
||||||
|
window = window,
|
||||||
|
startupSplash = startupSplash,
|
||||||
|
appWindowPlacement = windowState.placement,
|
||||||
|
readerFullscreen = readerFullscreen,
|
||||||
|
onReaderFullscreenChange = { readerFullscreen = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpistemeDesktopStartupGate(
|
||||||
|
window: Component?,
|
||||||
|
startupSplash: DesktopStartupSplash?,
|
||||||
|
appWindowPlacement: WindowPlacement,
|
||||||
|
readerFullscreen: Boolean,
|
||||||
|
onReaderFullscreenChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
var showApp by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(startupSplash) {
|
||||||
|
onDispose {
|
||||||
|
startupSplash?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showApp) {
|
||||||
|
EpistemeDesktopApp(
|
||||||
|
window = window,
|
||||||
|
appWindowPlacement = appWindowPlacement,
|
||||||
|
readerFullscreen = readerFullscreen,
|
||||||
|
onReaderFullscreenChange = onReaderFullscreenChange
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EpistemeDesktopStartupScreen(window = window)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
withFrameNanos { }
|
||||||
|
startupSplash?.close()
|
||||||
|
delay(80L)
|
||||||
|
showApp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpistemeDesktopStartupScreen(window: Component?) {
|
||||||
|
val appTitle = remember { epistemeDesktopWindowDefaults().title }
|
||||||
|
SharedAppTheme(
|
||||||
|
appThemeMode = AppThemeMode.SYSTEM,
|
||||||
|
appContrastOption = AppContrastOption.STANDARD,
|
||||||
|
appTextDimFactorLight = 1.0f,
|
||||||
|
appTextDimFactorDark = 1.0f,
|
||||||
|
appSeedColor = DesktopDefaultAppSeedColor
|
||||||
|
) {
|
||||||
|
EpistemeDesktopWindowChromeEffect(
|
||||||
|
window = window,
|
||||||
|
captionColor = MaterialTheme.colorScheme.surface,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
borderColor = MaterialTheme.colorScheme.background
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.padding(32.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(EpistemeDesktopWindowIconResource),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = appTitle,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Opening your library",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
|
||||||
|
internal const val ComposeInteropBlendingEnabled = "true"
|
||||||
|
private const val DesktopWindowStatePersistDebounceMillis = 450L
|
||||||
|
|
||||||
|
internal fun configureComposeSwingInterop() {
|
||||||
|
// Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed
|
||||||
|
// JCEF WebView, and current Compose interop can leave a stale black native rectangle after
|
||||||
|
// that reader surface is removed unless interop blending is enabled.
|
||||||
|
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
||||||
|
System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopWindowStatePersistenceEffect(
|
||||||
|
windowState: WindowState,
|
||||||
|
store: DesktopWindowStateStore,
|
||||||
|
enabled: Boolean
|
||||||
|
) {
|
||||||
|
val persistenceEnabled by rememberUpdatedState(enabled)
|
||||||
|
LaunchedEffect(windowState, store) {
|
||||||
|
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { snapshot ->
|
||||||
|
if (!persistenceEnabled || snapshot == null) return@collectLatest
|
||||||
|
delay(DesktopWindowStatePersistDebounceMillis)
|
||||||
|
if (persistenceEnabled) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
store.save(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderFullscreenEffect(
|
||||||
|
window: Component?,
|
||||||
|
enabled: Boolean
|
||||||
|
) {
|
||||||
|
val awtWindow = window as? java.awt.Window ?: return
|
||||||
|
val fullscreenSnapshot = remember(awtWindow) {
|
||||||
|
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||||
|
}
|
||||||
|
val pendingExitSnapshot = remember(awtWindow) {
|
||||||
|
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(awtWindow, enabled) {
|
||||||
|
if (!enabled && fullscreenSnapshot.get() == null) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(if (enabled) 180L else 80L)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (enabled) {
|
||||||
|
pendingExitSnapshot.set(null)
|
||||||
|
awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot)
|
||||||
|
} else {
|
||||||
|
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(120L)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (!enabled) {
|
||||||
|
awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null))
|
||||||
|
}
|
||||||
|
awtWindow.refreshDesktopReaderWindowFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(awtWindow) {
|
||||||
|
onDispose {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopReaderFullscreenSnapshot(
|
||||||
|
val device: GraphicsDevice?,
|
||||||
|
val frameState: Int?,
|
||||||
|
val frameBounds: Rectangle?,
|
||||||
|
val alwaysOnTop: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun java.awt.Window.enterDesktopReaderFullscreen(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||||
|
) {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
focusableWindowState = true
|
||||||
|
captureDesktopReaderFullscreenSnapshot(snapshotRef)
|
||||||
|
applyDesktopReaderBorderlessFullscreen(snapshotRef.get())
|
||||||
|
refreshDesktopReaderWindowFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||||
|
) {
|
||||||
|
snapshotRef.compareAndSet(
|
||||||
|
null,
|
||||||
|
DesktopReaderFullscreenSnapshot(
|
||||||
|
device = graphicsConfiguration?.device,
|
||||||
|
frameState = (this as? Frame)?.extendedState,
|
||||||
|
frameBounds = bounds.desktopReaderCopy(),
|
||||||
|
alwaysOnTop = isAlwaysOnTop
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
val device = snapshot?.device ?: graphicsConfiguration?.device
|
||||||
|
val frame = this as? Frame
|
||||||
|
focusableWindowState = true
|
||||||
|
if (frame != null) {
|
||||||
|
frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv()
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
}
|
||||||
|
device?.let { fullscreenDevice ->
|
||||||
|
runCatching {
|
||||||
|
if (fullscreenDevice.fullScreenWindow == this) {
|
||||||
|
fullscreenDevice.fullScreenWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.beginDesktopReaderFullscreenExit(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>,
|
||||||
|
pendingExitSnapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>? = null
|
||||||
|
) {
|
||||||
|
val snapshot = snapshotRef.getAndSet(null)
|
||||||
|
if (snapshot == null) return
|
||||||
|
pendingExitSnapshotRef?.set(snapshot)
|
||||||
|
val device = snapshot.device ?: graphicsConfiguration?.device
|
||||||
|
device?.let { fullscreenDevice ->
|
||||||
|
runCatching {
|
||||||
|
if (fullscreenDevice.fullScreenWindow == this) {
|
||||||
|
fullscreenDevice.fullScreenWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||||
|
}
|
||||||
|
if (!isVisible) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
(this as? Frame)?.let { frame ->
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
frame.extendedState = Frame.NORMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||||
|
if (snapshot == null) return
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||||
|
}
|
||||||
|
if (!isVisible) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val frame = this as? Frame
|
||||||
|
if (frame == null) {
|
||||||
|
bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val restoreMaximized = snapshot.frameState?.let { state ->
|
||||||
|
state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH
|
||||||
|
} == true
|
||||||
|
frame.extendedState = Frame.NORMAL
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||||
|
if (restoreMaximized) {
|
||||||
|
frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds()
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (frame.isDisplayable && frame.isShowing) {
|
||||||
|
frame.extendedState = Frame.MAXIMIZED_BOTH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame.toFront()
|
||||||
|
frame.requestFocus()
|
||||||
|
frame.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle {
|
||||||
|
return defaultConfiguration.bounds.desktopReaderCopy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? {
|
||||||
|
val configuration = defaultConfiguration ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val bounds = configuration.bounds
|
||||||
|
val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration)
|
||||||
|
Rectangle(
|
||||||
|
bounds.x + insets.left,
|
||||||
|
bounds.y + insets.top,
|
||||||
|
(bounds.width - insets.left - insets.right).coerceAtLeast(1),
|
||||||
|
(bounds.height - insets.top - insets.bottom).coerceAtLeast(1)
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle {
|
||||||
|
val usableBounds = device?.desktopReaderUsableBounds()
|
||||||
|
?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820)
|
||||||
|
val source = this ?: usableBounds
|
||||||
|
val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640))
|
||||||
|
val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480))
|
||||||
|
val looksFullscreen = source.x <= usableBounds.x &&
|
||||||
|
source.y <= usableBounds.y &&
|
||||||
|
source.width >= usableBounds.width &&
|
||||||
|
source.height >= usableBounds.height
|
||||||
|
if (looksFullscreen) {
|
||||||
|
return usableBounds.desktopReaderCopy()
|
||||||
|
}
|
||||||
|
val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x)
|
||||||
|
val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y)
|
||||||
|
return Rectangle(
|
||||||
|
source.x.coerceIn(usableBounds.x, maxX),
|
||||||
|
source.y.coerceIn(usableBounds.y, maxY),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Rectangle.desktopReaderCopy(): Rectangle {
|
||||||
|
return Rectangle(x, y, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
if (this is Frame && extendedState and Frame.ICONIFIED != 0) {
|
||||||
|
extendedState = extendedState and Frame.ICONIFIED.inv()
|
||||||
|
}
|
||||||
|
toFront()
|
||||||
|
requestFocus()
|
||||||
|
requestFocusInWindow()
|
||||||
|
focusOwner?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderFullscreenKeyEffect(
|
||||||
|
enabled: Boolean,
|
||||||
|
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||||
|
) {
|
||||||
|
val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
|
||||||
|
DisposableEffect(enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
onDispose {}
|
||||||
|
} else {
|
||||||
|
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||||
|
val dispatcher = java.awt.KeyEventDispatcher { event ->
|
||||||
|
val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true
|
||||||
|
!modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
|
||||||
|
}
|
||||||
|
focusManager.addKeyEventDispatcher(dispatcher)
|
||||||
|
onDispose {
|
||||||
|
focusManager.removeKeyEventDispatcher(dispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean {
|
||||||
|
val windowTitle = when (this) {
|
||||||
|
is java.awt.Dialog -> title
|
||||||
|
is Frame -> title
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true ||
|
||||||
|
windowTitle.startsWith("Reader Panel") ||
|
||||||
|
windowTitle.startsWith("Reader Popup")
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||||
|
|
||||||
|
internal data class DesktopWebViewRuntimeState(
|
||||||
|
val initialized: Boolean = false,
|
||||||
|
val restartRequired: Boolean = false,
|
||||||
|
val downloadProgress: Float = -1f,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean {
|
||||||
|
return readerSurface == ReaderFeatureSurface.TEXT_READER
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldStartDesktopWebViewRuntime(
|
||||||
|
requested: Boolean,
|
||||||
|
state: DesktopWebViewRuntimeState
|
||||||
|
): Boolean {
|
||||||
|
return requested && !state.initialized && !state.restartRequired && state.errorMessage == null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopWebViewRuntimeIndicator(
|
||||||
|
state: DesktopWebViewRuntimeState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val message = when {
|
||||||
|
state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}"
|
||||||
|
state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup."
|
||||||
|
state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%"
|
||||||
|
else -> "Preparing embedded webview..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (state.errorMessage == null && !state.restartRequired) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (state.downloadProgress in 0f..100f) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { state.downloadProgress / 100f },
|
||||||
|
modifier = Modifier.width(260.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.ReaderLocator
|
||||||
|
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
internal data class DesktopReaderPosition(
|
||||||
|
val pageIndex: Int,
|
||||||
|
val locator: ReaderLocator?
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopReaderHighlightClick(
|
||||||
|
val highlightId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopEpubLinkClick(
|
||||||
|
val href: String,
|
||||||
|
val chapterIndex: Int?,
|
||||||
|
val text: String? = null,
|
||||||
|
val chapterId: String? = null,
|
||||||
|
val chapterHref: String? = null,
|
||||||
|
val source: String = "bridge"
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
|
||||||
|
return DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
text = text,
|
||||||
|
source = "native"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopEpubHandledLink(
|
||||||
|
val href: String,
|
||||||
|
val handledAtMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
internal enum class DesktopReaderSelectionAction {
|
||||||
|
DEFINE,
|
||||||
|
SPEAK,
|
||||||
|
SEARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DesktopReaderKeyNavigation {
|
||||||
|
NEXT,
|
||||||
|
PREVIOUS,
|
||||||
|
FIRST,
|
||||||
|
LAST,
|
||||||
|
SEARCH,
|
||||||
|
NEXT_SEARCH,
|
||||||
|
EXIT_FULLSCREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
|
||||||
|
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||||
|
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||||
|
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||||
|
}
|
||||||
|
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
|
||||||
|
return DesktopReaderKeyNavigation.SEARCH
|
||||||
|
}
|
||||||
|
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
|
||||||
|
return DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||||
|
}
|
||||||
|
return when (keyCode) {
|
||||||
|
AwtKeyEvent.VK_RIGHT,
|
||||||
|
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
|
||||||
|
AwtKeyEvent.VK_LEFT,
|
||||||
|
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
|
||||||
|
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopReaderSelectionActionPayload(
|
||||||
|
val action: DesktopReaderSelectionAction,
|
||||||
|
val text: String
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val highlightId = obj["id"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj["highlightId"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
DesktopReaderHighlightClick(highlightId)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val text = obj["text"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
val action = when (
|
||||||
|
obj["action"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.lowercase()
|
||||||
|
) {
|
||||||
|
"define" -> DesktopReaderSelectionAction.DEFINE
|
||||||
|
"speak" -> DesktopReaderSelectionAction.SPEAK
|
||||||
|
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
||||||
|
else -> return@runCatching null
|
||||||
|
}
|
||||||
|
DesktopReaderSelectionActionPayload(action, text)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerSelectionDebugMessageOrNull(): String? {
|
||||||
|
fun parse(rawJson: String): String? = runCatching {
|
||||||
|
Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["message"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerPaginationLogMessageOrNull(): String? {
|
||||||
|
fun parse(rawJson: String): String? = runCatching {
|
||||||
|
Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["message"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val pageIndex = obj["pageIndex"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.intOrNull
|
||||||
|
?: return@runCatching null
|
||||||
|
val locator = ReaderLocator(
|
||||||
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||||
|
)
|
||||||
|
DesktopReaderPosition(pageIndex, locator)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
|
||||||
|
val action = Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["action"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?: return@runCatching null
|
||||||
|
when (action) {
|
||||||
|
"next" -> DesktopReaderKeyNavigation.NEXT
|
||||||
|
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
"first" -> DesktopReaderKeyNavigation.FIRST
|
||||||
|
"last" -> DesktopReaderKeyNavigation.LAST
|
||||||
|
"search" -> DesktopReaderKeyNavigation.SEARCH
|
||||||
|
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||||
|
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
|
||||||
|
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val href = obj["href"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
|
||||||
|
val trimmed = trim()
|
||||||
|
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
|
||||||
|
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
|
||||||
|
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
|
||||||
|
.split('&')
|
||||||
|
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
|
||||||
|
?.substringAfter("=", missingDelimiterValue = "")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (payload == null) {
|
||||||
|
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val decoded = runCatching {
|
||||||
|
URLDecoder.decode(payload, Charsets.UTF_8.name())
|
||||||
|
}.getOrElse {
|
||||||
|
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
|
||||||
|
if (link == null) {
|
||||||
|
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
return readerHrefFromIntercept()?.let { href ->
|
||||||
|
DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = null,
|
||||||
|
source = "request"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.readerHrefFromIntercept(): String? {
|
||||||
|
val trimmed = trim()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("file://", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
||||||
|
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderLocator.toReaderLocatorJson(): String {
|
||||||
|
return buildString {
|
||||||
|
append("{")
|
||||||
|
val values = buildList {
|
||||||
|
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
||||||
|
pageIndex?.let { add("\"pageIndex\":$it") }
|
||||||
|
startOffset?.let { add("\"startOffset\":$it") }
|
||||||
|
endOffset?.let { add("\"endOffset\":$it") }
|
||||||
|
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
||||||
|
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
||||||
|
}
|
||||||
|
append(values.joinToString(","))
|
||||||
|
append("}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonStringLiteral(): String {
|
||||||
|
val builder = StringBuilder("\"")
|
||||||
|
forEach { char ->
|
||||||
|
when (char) {
|
||||||
|
'\\' -> builder.append("\\\\")
|
||||||
|
'"' -> builder.append("\\\"")
|
||||||
|
'\n' -> builder.append("\\n")
|
||||||
|
'\r' -> builder.append("\\r")
|
||||||
|
'\t' -> builder.append("\\t")
|
||||||
|
'\b' -> builder.append("\\b")
|
||||||
|
'\u000C' -> builder.append("\\f")
|
||||||
|
else -> {
|
||||||
|
if (char.code < 0x20) {
|
||||||
|
builder.append("\\u")
|
||||||
|
builder.append(char.code.toString(16).padStart(4, '0'))
|
||||||
|
} else {
|
||||||
|
builder.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.append('"')
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.reader.ReaderLayoutSignature
|
||||||
|
import com.aryan.reader.shared.reader.ReaderViewportSpec
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||||
|
|
||||||
|
internal data class DesktopEpubPaginationRequest(
|
||||||
|
val bookId: String,
|
||||||
|
val chapterSignature: Int,
|
||||||
|
val layoutSignature: ReaderLayoutSignature,
|
||||||
|
val viewport: ReaderViewportSpec,
|
||||||
|
val density: DesktopEpubPaginationDensity,
|
||||||
|
val cacheGeneration: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopEpubPaginationDensity(
|
||||||
|
val density: Float,
|
||||||
|
val fontScale: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
|
||||||
|
return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
|
||||||
|
31 * acc +
|
||||||
|
chapter.id.hashCode() +
|
||||||
|
chapter.plainText.length +
|
||||||
|
chapter.plainText.hashCode() +
|
||||||
|
chapter.semanticBlocks.hashCode() +
|
||||||
|
chapter.htmlContent.length +
|
||||||
|
chapter.htmlContent.hashCode() +
|
||||||
|
chapter.baseHref.orEmpty().hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopEpubPaginationPreparing(
|
||||||
|
active: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
if (active) "Preparing pages" else "Measuring reader layout",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme"
|
||||||
|
internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues"
|
||||||
|
internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112"
|
||||||
|
internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader"
|
||||||
|
internal const val EpistemeSupportEmail = "epistemereader@gmail.com"
|
||||||
|
|
||||||
|
private const val ExternalLinkLogTag = "EpistemeExternalLink"
|
||||||
|
|
||||||
|
internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String {
|
||||||
|
return "Feedback: ${profile.appName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopAppVersionName(): String {
|
||||||
|
val version = System.getProperty(DesktopVersionProperty)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return version?.let { "Version $it" } ?: "Version unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
private object EpistemeDesktopAppVersion
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopExternalLinkDialog(
|
||||||
|
url: String?,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
if (url == null) return
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
logExternalLink("dialog_show url=\"${url.logPreview()}\"")
|
||||||
|
}
|
||||||
|
fun dismiss() {
|
||||||
|
logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"")
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
DesktopReaderBottomSheet(
|
||||||
|
title = "External link",
|
||||||
|
onDismiss = ::dismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"You clicked an external link.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
url,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = ::dismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
logExternalLink("dialog_copy url=\"${url.logPreview()}\"")
|
||||||
|
clipboardManager.setText(AnnotatedString(url))
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Copy")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
logExternalLink("dialog_open url=\"${url.logPreview()}\"")
|
||||||
|
openExternalUrl(url)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openExternalUrl(url: String) {
|
||||||
|
if (!currentDesktopBuildProfile().featurePolicy.projectLinks) {
|
||||||
|
logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val normalizedUrl = url.normalizedExternalUrl()
|
||||||
|
runCatching {
|
||||||
|
if (Desktop.isDesktopSupported()) {
|
||||||
|
val desktop = Desktop.getDesktop()
|
||||||
|
if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) {
|
||||||
|
desktop.mail(URI(normalizedUrl))
|
||||||
|
} else {
|
||||||
|
desktop.browse(URI(normalizedUrl))
|
||||||
|
}
|
||||||
|
logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"")
|
||||||
|
} else {
|
||||||
|
logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"")
|
||||||
|
}
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.normalizedExternalUrl(): String {
|
||||||
|
val trimmed = trim()
|
||||||
|
return if (trimmed.startsWith("www.", ignoreCase = true)) {
|
||||||
|
"https://$trimmed"
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.isRemoteNetworkUrl(): Boolean {
|
||||||
|
val trimmed = trim()
|
||||||
|
return trimmed.startsWith("http://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("https://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("ws://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("wss://", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.urlEncode(): String {
|
||||||
|
return URLEncoder.encode(this, Charsets.UTF_8.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logExternalLink(message: String) {
|
||||||
|
logDesktopDiagnostic(ExternalLinkLogTag) { message }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.FileType
|
||||||
|
import com.aryan.reader.shared.ImportedBookFile
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
|
||||||
|
internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
internal val DesktopBookFileTypes = DesktopReadableFileTypes
|
||||||
|
private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
|
||||||
|
.filter { it.type in DesktopBookFileTypes }
|
||||||
|
.flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } }
|
||||||
|
.joinToString(";")
|
||||||
|
|
||||||
|
internal fun desktopBookFileTypesForDialog(): Set<FileType> = DesktopBookFileTypes
|
||||||
|
|
||||||
|
internal fun chooseFiles(): List<ImportedBookFile> {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply {
|
||||||
|
isMultipleMode = true
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseBookFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply {
|
||||||
|
file = DesktopBookFileDialogPattern
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun choosePdfFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply {
|
||||||
|
file = "*.pdf"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseFontFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply {
|
||||||
|
file = "*.ttf;*.otf;*.woff2"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseReaderTextureFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply {
|
||||||
|
file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseFolder(): File? {
|
||||||
|
val chooser = JFileChooser().apply {
|
||||||
|
dialogTitle = "Import folder"
|
||||||
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
|
isAcceptAllFileFilterUsed = false
|
||||||
|
}
|
||||||
|
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||||
|
chooser.selectedFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ImportedBookFile.desktopFileType(): FileType {
|
||||||
|
return SharedFileCapabilities.fileTypeForName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
|
||||||
|
return ImportedBookFile(
|
||||||
|
name = name,
|
||||||
|
uriString = null,
|
||||||
|
localPath = absolutePath,
|
||||||
|
size = length(),
|
||||||
|
sourceFolder = sourceFolder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.aryan.reader.shared.ImportedBookFile
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.Container
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
|
import java.awt.dnd.DnDConstants
|
||||||
|
import java.awt.dnd.DropTarget
|
||||||
|
import java.awt.dnd.DropTargetAdapter
|
||||||
|
import java.awt.dnd.DropTargetDragEvent
|
||||||
|
import java.awt.dnd.DropTargetDropEvent
|
||||||
|
import java.awt.dnd.DropTargetEvent
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal data class DesktopDropImportState(
|
||||||
|
val active: Boolean = false,
|
||||||
|
val supportedCount: Int = 0,
|
||||||
|
val totalFileCount: Int = 0,
|
||||||
|
val hasFilePayload: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopFileDropTarget(
|
||||||
|
window: Component?,
|
||||||
|
onFilesDropped: (List<ImportedBookFile>) -> Unit,
|
||||||
|
onDragStateChange: (DesktopDropImportState) -> Unit
|
||||||
|
) {
|
||||||
|
val onFilesDroppedState = rememberUpdatedState(onFilesDropped)
|
||||||
|
val onDragStateChangeState = rememberUpdatedState(onDragStateChange)
|
||||||
|
|
||||||
|
DisposableEffect(window) {
|
||||||
|
if (window == null) {
|
||||||
|
onDispose { }
|
||||||
|
} else {
|
||||||
|
val installedTargets = mutableListOf<InstalledDropTarget>()
|
||||||
|
var disposed = false
|
||||||
|
var lastDragState = DesktopDropImportState()
|
||||||
|
|
||||||
|
fun publishDragState(state: DesktopDropImportState) {
|
||||||
|
if (state == lastDragState) return
|
||||||
|
lastDragState = state
|
||||||
|
onDragStateChangeState.value(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = object : DropTargetAdapter() {
|
||||||
|
override fun dragEnter(event: DropTargetDragEvent) {
|
||||||
|
handleDrag(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dragOver(event: DropTargetDragEvent) {
|
||||||
|
handleDrag(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dragExit(event: DropTargetEvent) {
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun drop(event: DropTargetDropEvent) {
|
||||||
|
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||||
|
event.rejectDrop()
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.acceptDrop(DnDConstants.ACTION_COPY)
|
||||||
|
val files = event.transferable.localDraggedFiles().filter { it.isFile }
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
event.dropComplete(false)
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesDroppedState.value(files.map { it.toDesktopImportedBookFile() })
|
||||||
|
event.dropComplete(true)
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDrag(event: DropTargetDragEvent) {
|
||||||
|
val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)
|
||||||
|
publishDragState(
|
||||||
|
DesktopDropImportState(
|
||||||
|
active = true,
|
||||||
|
hasFilePayload = hasFilePayload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (hasFilePayload) {
|
||||||
|
event.acceptDrag(DnDConstants.ACTION_COPY)
|
||||||
|
} else {
|
||||||
|
event.rejectDrag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.installDropTargets(listener, installedTargets)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (!disposed) {
|
||||||
|
window.installDropTargets(listener, installedTargets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
disposed = true
|
||||||
|
installedTargets.forEach { installed ->
|
||||||
|
runCatching { installed.dropTarget.removeDropTargetListener(listener) }
|
||||||
|
installed.component.dropTarget = installed.previous
|
||||||
|
}
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class InstalledDropTarget(
|
||||||
|
val component: Component,
|
||||||
|
val previous: DropTarget?,
|
||||||
|
val dropTarget: DropTarget
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Component.installDropTargets(
|
||||||
|
listener: DropTargetAdapter,
|
||||||
|
installedTargets: MutableList<InstalledDropTarget>
|
||||||
|
) {
|
||||||
|
collectDropTargetComponents()
|
||||||
|
.distinct()
|
||||||
|
.filterNot { component -> installedTargets.any { it.component == component } }
|
||||||
|
.forEach { component ->
|
||||||
|
val previous = component.dropTarget
|
||||||
|
val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true)
|
||||||
|
installedTargets += InstalledDropTarget(component, previous, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Component.collectDropTargetComponents(): List<Component> {
|
||||||
|
val collected = mutableListOf<Component>()
|
||||||
|
|
||||||
|
fun visit(component: Component) {
|
||||||
|
collected += component
|
||||||
|
if (component is Container) {
|
||||||
|
component.components.forEach(::visit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(this)
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopDropImportOverlay(state: DesktopDropImportState) {
|
||||||
|
if (!state.active) return
|
||||||
|
|
||||||
|
val hasSupportedFiles = state.supportedCount > 0
|
||||||
|
val title = when {
|
||||||
|
hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}"
|
||||||
|
state.hasFilePayload -> "Drop supported files to import"
|
||||||
|
else -> "Drop files to import"
|
||||||
|
}
|
||||||
|
val body = if (hasSupportedFiles) {
|
||||||
|
val skipped = state.totalFileCount - state.supportedCount
|
||||||
|
if (skipped > 0) {
|
||||||
|
"$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped."
|
||||||
|
} else {
|
||||||
|
"Release to add to your library."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(20f)
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List<File> {
|
||||||
|
if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(getTransferData(DataFlavor.javaFileListFlavor) as? List<*>)
|
||||||
|
.orEmpty()
|
||||||
|
.filterIsInstance<File>()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,506 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.AppAction
|
||||||
|
import com.aryan.reader.shared.BannerMessage
|
||||||
|
import com.aryan.reader.shared.BookItem
|
||||||
|
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||||
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.Shelf
|
||||||
|
import com.aryan.reader.shared.SmartCollectionDefinition
|
||||||
|
import com.aryan.reader.shared.SmartField
|
||||||
|
import com.aryan.reader.shared.SmartOperator
|
||||||
|
import com.aryan.reader.shared.SmartRule
|
||||||
|
import com.aryan.reader.shared.Tag
|
||||||
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import com.aryan.reader.shared.reduce
|
||||||
|
import com.aryan.reader.shared.ui.NonReaderLibraryTab
|
||||||
|
import com.aryan.reader.shared.ui.SharedHomeScreen
|
||||||
|
import com.aryan.reader.shared.ui.SharedLibraryScreen
|
||||||
|
import com.aryan.reader.shared.ui.SharedShelvesScreen
|
||||||
|
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean {
|
||||||
|
return title != updated.title ||
|
||||||
|
author != updated.author ||
|
||||||
|
description != updated.description ||
|
||||||
|
seriesName != updated.seriesName ||
|
||||||
|
seriesIndex != updated.seriesIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.toDesktopSafeFileName(): String {
|
||||||
|
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.withDesktopImportMetadata(
|
||||||
|
enriched: BookItem,
|
||||||
|
original: BookItem?
|
||||||
|
): BookItem {
|
||||||
|
fun shouldApplyText(current: String?, originalValue: String?): Boolean {
|
||||||
|
return current.isNullOrBlank() || current == originalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(
|
||||||
|
title = if (shouldApplyText(title, original?.title)) {
|
||||||
|
enriched.title ?: title
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
},
|
||||||
|
author = if (shouldApplyText(author, original?.author)) {
|
||||||
|
enriched.author ?: author
|
||||||
|
} else {
|
||||||
|
author
|
||||||
|
},
|
||||||
|
description = if (shouldApplyText(description, original?.description)) {
|
||||||
|
enriched.description ?: description
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
},
|
||||||
|
seriesName = if (shouldApplyText(seriesName, original?.seriesName)) {
|
||||||
|
enriched.seriesName ?: seriesName
|
||||||
|
} else {
|
||||||
|
seriesName
|
||||||
|
},
|
||||||
|
seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) {
|
||||||
|
enriched.seriesIndex ?: seriesIndex
|
||||||
|
} else {
|
||||||
|
seriesIndex
|
||||||
|
},
|
||||||
|
originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title,
|
||||||
|
originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author,
|
||||||
|
originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex,
|
||||||
|
originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description,
|
||||||
|
fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize,
|
||||||
|
fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: fileContentModifiedTimestamp,
|
||||||
|
coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolvedDesktopReaderSettings(
|
||||||
|
book: BookItem,
|
||||||
|
readerDefaultSettings: ReaderSettings
|
||||||
|
): ReaderSettings {
|
||||||
|
return book.readerSettings ?: readerDefaultSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderOpeningScreen(
|
||||||
|
opening: DesktopReaderOpening,
|
||||||
|
onReturnToLibrary: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = "Opening ${opening.title}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = opening.formatLabel,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
TextButton(onClick = onReturnToLibrary) {
|
||||||
|
Text("Return to library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HomeScreen(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
onImportBooks: () -> Unit,
|
||||||
|
onImportFolder: () -> Unit,
|
||||||
|
onRead: (BookItem) -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onClearSelection: () -> Unit,
|
||||||
|
onRemoveSelected: () -> Unit,
|
||||||
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onTagSelectedBooks: () -> Unit,
|
||||||
|
onAddSelectedBooksToShelf: () -> Unit,
|
||||||
|
onOpenTab: (BookItem) -> Unit,
|
||||||
|
onCloseTab: (BookItem) -> Unit,
|
||||||
|
onCloseAllTabs: () -> Unit,
|
||||||
|
onRecentLimitChange: (Int) -> Unit,
|
||||||
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit
|
||||||
|
) {
|
||||||
|
SharedHomeScreen(
|
||||||
|
state = state,
|
||||||
|
onImportBooks = onImportBooks,
|
||||||
|
onImportFolder = onImportFolder,
|
||||||
|
onOpenBook = onRead,
|
||||||
|
onToggleSelection = onSelect,
|
||||||
|
onClearSelection = onClearSelection,
|
||||||
|
onRemoveSelected = onRemoveSelected,
|
||||||
|
onShowBookInfo = onShowBookInfo,
|
||||||
|
onEditBook = onEditBook,
|
||||||
|
onTagSelectedBooks = onTagSelectedBooks,
|
||||||
|
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||||
|
onOpenTab = onOpenTab,
|
||||||
|
onCloseTab = onCloseTab,
|
||||||
|
onCloseAllTabs = onCloseAllTabs,
|
||||||
|
onRecentLimitChange = onRecentLimitChange,
|
||||||
|
onTogglePinned = onTogglePinned,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
showActiveTabs = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryScreen(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
selectedLibraryTab: NonReaderLibraryTab,
|
||||||
|
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
|
||||||
|
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||||
|
onImportBooks: () -> Unit,
|
||||||
|
onRead: (BookItem) -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onClearSelection: () -> Unit,
|
||||||
|
onRemoveSelected: () -> Unit,
|
||||||
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onCreateShelf: () -> Unit,
|
||||||
|
onCreateSmartShelf: () -> Unit,
|
||||||
|
onRenameShelf: (Shelf) -> Unit,
|
||||||
|
onDeleteShelf: (Shelf) -> Unit,
|
||||||
|
onRemoveFolder: (Shelf) -> Unit,
|
||||||
|
onTagSelectedBooks: () -> Unit,
|
||||||
|
onAddSelectedBooksToShelf: () -> Unit,
|
||||||
|
onImportFolder: () -> Unit,
|
||||||
|
onSyncFolderMetadata: () -> Unit,
|
||||||
|
onScanFolders: () -> Unit,
|
||||||
|
onTogglePinned: (BookItem) -> Unit
|
||||||
|
) {
|
||||||
|
SharedLibraryScreen(
|
||||||
|
state = state,
|
||||||
|
selectedTab = selectedLibraryTab,
|
||||||
|
onTabChange = onLibraryTabChange,
|
||||||
|
onStateChange = onStateChange,
|
||||||
|
onImportBooks = onImportBooks,
|
||||||
|
onOpenBook = onRead,
|
||||||
|
onToggleSelection = onSelect,
|
||||||
|
onClearSelection = onClearSelection,
|
||||||
|
onRemoveSelected = onRemoveSelected,
|
||||||
|
onShowBookInfo = onShowBookInfo,
|
||||||
|
onEditBook = onEditBook,
|
||||||
|
onCreateShelf = onCreateShelf,
|
||||||
|
onCreateSmartShelf = onCreateSmartShelf,
|
||||||
|
onRenameShelf = onRenameShelf,
|
||||||
|
onDeleteShelf = onDeleteShelf,
|
||||||
|
onRemoveFolder = onRemoveFolder,
|
||||||
|
onTagSelectedBooks = onTagSelectedBooks,
|
||||||
|
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||||
|
onImportFolder = onImportFolder,
|
||||||
|
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||||
|
onScanFolders = onScanFolders,
|
||||||
|
onTogglePinned = onTogglePinned,
|
||||||
|
useImportEmptyStateWhenLibraryEmpty = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ShelvesScreen(
|
||||||
|
shelves: List<Shelf>,
|
||||||
|
selectedBookIds: Set<String>,
|
||||||
|
pinnedBookIds: Set<String>,
|
||||||
|
onRead: (BookItem) -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onCreateShelf: () -> Unit,
|
||||||
|
onCreateSmartShelf: () -> Unit,
|
||||||
|
onRenameShelf: (Shelf) -> Unit,
|
||||||
|
onDeleteShelf: (Shelf) -> Unit,
|
||||||
|
onRemoveFolder: (Shelf) -> Unit
|
||||||
|
) {
|
||||||
|
SharedShelvesScreen(
|
||||||
|
shelves = shelves,
|
||||||
|
selectedBookIds = selectedBookIds,
|
||||||
|
pinnedBookIds = pinnedBookIds,
|
||||||
|
onOpenBook = onRead,
|
||||||
|
onToggleSelection = onSelect,
|
||||||
|
onShowBookInfo = onShowBookInfo,
|
||||||
|
onEditBook = onEditBook,
|
||||||
|
onTogglePinned = onTogglePinned,
|
||||||
|
onCreateShelf = onCreateShelf,
|
||||||
|
onCreateSmartShelf = onCreateSmartShelf,
|
||||||
|
onRenameShelf = onRenameShelf,
|
||||||
|
onDeleteShelf = onDeleteShelf,
|
||||||
|
onRemoveFolder = onRemoveFolder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopSmartRuleDraft(
|
||||||
|
val field: SmartField = SmartField.TITLE,
|
||||||
|
val operator: SmartOperator = SmartOperator.CONTAINS,
|
||||||
|
val value: String = ""
|
||||||
|
) {
|
||||||
|
fun toRule(): SmartRule? {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
return SmartRule(field = field, operator = operator, value = trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SmartShelfDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String, SmartCollectionDefinition) -> Unit
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var matchAll by remember { mutableStateOf(true) }
|
||||||
|
var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) }
|
||||||
|
val validRules = rules.mapNotNull { it.toRule() }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Create smart shelf") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Shelf name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
FilterChip(
|
||||||
|
selected = matchAll,
|
||||||
|
onClick = { matchAll = true },
|
||||||
|
label = { Text("All") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = !matchAll,
|
||||||
|
onClick = { matchAll = false },
|
||||||
|
label = { Text("Any") }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = { rules = rules + DesktopSmartRuleDraft() },
|
||||||
|
enabled = rules.size < 4
|
||||||
|
) {
|
||||||
|
Text("Add rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules.forEachIndexed { index, draft ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SmartRuleDropdown(
|
||||||
|
label = "Field",
|
||||||
|
selected = draft.field,
|
||||||
|
options = SmartField.entries.toList(),
|
||||||
|
optionLabel = { it.desktopLabel() },
|
||||||
|
onSelected = { field ->
|
||||||
|
rules = rules.updateAt(index) {
|
||||||
|
val operator = smartOperatorsFor(field).first()
|
||||||
|
copy(field = field, operator = operator, value = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SmartRuleDropdown(
|
||||||
|
label = "Operator",
|
||||||
|
selected = draft.operator,
|
||||||
|
options = smartOperatorsFor(draft.field),
|
||||||
|
optionLabel = { it.desktopLabel() },
|
||||||
|
onSelected = { operator ->
|
||||||
|
rules = rules.updateAt(index) { copy(operator = operator) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (rules.size > 1) {
|
||||||
|
TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) {
|
||||||
|
Text("Remove")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = draft.value,
|
||||||
|
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
|
||||||
|
label = { Text(draft.field.valueLabel()) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectionKey = index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules))
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && validRules.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text("Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> SmartRuleDropdown(
|
||||||
|
label: String,
|
||||||
|
selected: T,
|
||||||
|
options: List<T>,
|
||||||
|
optionLabel: (T) -> String,
|
||||||
|
onSelected: (T) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { expanded = true }) {
|
||||||
|
Text("$label: ${optionLabel(selected)}")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel(option)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelected(option)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
|
||||||
|
return when (field) {
|
||||||
|
SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS)
|
||||||
|
else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SmartField.desktopLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartField.TITLE -> "Title"
|
||||||
|
SmartField.AUTHOR -> "Author"
|
||||||
|
SmartField.PROGRESS -> "Progress"
|
||||||
|
SmartField.FILE_TYPE -> "File type"
|
||||||
|
SmartField.FOLDER -> "Folder"
|
||||||
|
SmartField.TAG -> "Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SmartField.valueLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartField.PROGRESS -> "Percent"
|
||||||
|
SmartField.FILE_TYPE -> "Type, e.g. PDF"
|
||||||
|
SmartField.FOLDER -> "Folder path"
|
||||||
|
SmartField.TAG -> "Tag name"
|
||||||
|
SmartField.TITLE -> "Title text"
|
||||||
|
SmartField.AUTHOR -> "Author text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SmartOperator.desktopLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartOperator.EQUALS -> "Equals"
|
||||||
|
SmartOperator.CONTAINS -> "Contains"
|
||||||
|
SmartOperator.GREATER_THAN -> "Greater than"
|
||||||
|
SmartOperator.LESS_THAN -> "Less than"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun List<DesktopSmartRuleDraft>.updateAt(
|
||||||
|
index: Int,
|
||||||
|
transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft
|
||||||
|
): List<DesktopSmartRuleDraft> {
|
||||||
|
return mapIndexed { i, draft -> if (i == index) draft.transform() else draft }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
|
||||||
|
return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DesktopFolderPathResolver : SharedFolderPathResolver {
|
||||||
|
override fun relativeFolderSegments(item: BookItem): List<String> {
|
||||||
|
val sourceFolder = item.sourceFolder ?: return emptyList()
|
||||||
|
val bookPath = item.path ?: return emptyList()
|
||||||
|
val parentFile = File(bookPath).parentFile ?: return emptyList()
|
||||||
|
val paths = runCatching {
|
||||||
|
File(sourceFolder).toPath().toAbsolutePath().normalize() to
|
||||||
|
parentFile.toPath().toAbsolutePath().normalize()
|
||||||
|
}.getOrNull() ?: return emptyList()
|
||||||
|
val (root, parent) = paths
|
||||||
|
if (!parent.startsWith(root) || parent == root) return emptyList()
|
||||||
|
return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<BookItem>.collectTags(): List<Tag> {
|
||||||
|
return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.cardTitleForMessage(): String {
|
||||||
|
return title?.takeIf { it.isNotBlank() } ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Long.toReadableSize(): String {
|
||||||
|
if (this <= 0L) return "Unknown"
|
||||||
|
val units = listOf("B", "KB", "MB", "GB", "TB")
|
||||||
|
var value = this.toDouble()
|
||||||
|
var unitIndex = 0
|
||||||
|
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||||
|
value /= 1024.0
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return if (unitIndex == 0) {
|
||||||
|
"$this ${units[unitIndex]}"
|
||||||
|
} else {
|
||||||
|
"${String.format("%.1f", value)} ${units[unitIndex]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
internal fun String.logPreview(maxLength: Int = 96): String {
|
||||||
|
return replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||||
|
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||||
|
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
||||||
|
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
||||||
|
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
|
||||||
|
import com.aryan.reader.shared.pdf.withSharedPdfTextStyle
|
||||||
|
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
|
||||||
|
import com.aryan.reader.shared.ui.SharedPdfTextStyleControls
|
||||||
|
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfAnnotationEditor(
|
||||||
|
annotation: SharedPdfAnnotation,
|
||||||
|
onUpdate: (SharedPdfAnnotation) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onCopy: () -> Unit,
|
||||||
|
showSearch: Boolean,
|
||||||
|
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||||
|
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {},
|
||||||
|
onSearch: () -> Unit
|
||||||
|
) {
|
||||||
|
val highlighterColors = remember(highlighterPalette) {
|
||||||
|
SharedPdfAndroidHighlightColors.palette
|
||||||
|
}
|
||||||
|
var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf<Int?>(null) }
|
||||||
|
val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT ||
|
||||||
|
annotation.tool == PdfInkTool.HIGHLIGHTER ||
|
||||||
|
annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Selected ${annotation.desktopLabel()}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClose) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Page ${annotation.pageIndex + 1}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
if (annotation.text.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = Color(annotation.colorArgb).copy(alpha = 0.10f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.heightIn(min = 72.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(6.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(Color(annotation.colorArgb))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"\"${annotation.text}\"",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f),
|
||||||
|
modifier = Modifier.padding(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
DesktopBottomSheetToolButton(
|
||||||
|
icon = Icons.Default.ContentCopy,
|
||||||
|
label = "Copy",
|
||||||
|
onClick = onCopy
|
||||||
|
)
|
||||||
|
if (showSearch) {
|
||||||
|
DesktopBottomSheetToolButton(
|
||||||
|
icon = Icons.Default.Search,
|
||||||
|
label = "Search",
|
||||||
|
onClick = onSearch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (annotation.kind == PdfAnnotationKind.TEXT) {
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = annotation.text,
|
||||||
|
onValueChange = { onUpdate(annotation.copy(text = it)) },
|
||||||
|
label = { Text("Text note") },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectionKey = annotation.id
|
||||||
|
)
|
||||||
|
SharedPdfTextStyleControls(
|
||||||
|
style = annotation.sharedPdfTextStyle(),
|
||||||
|
onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (annotation.kind != PdfAnnotationKind.TEXT) {
|
||||||
|
val palette = if (isHighlighterAnnotation) {
|
||||||
|
highlighterColors
|
||||||
|
} else {
|
||||||
|
SharedPdfAnnotationDefaults.penPalette
|
||||||
|
}
|
||||||
|
Text("Color", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
palette.forEachIndexed { _, argb ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(26.dp)
|
||||||
|
.clickable {
|
||||||
|
val nextColor = if (isHighlighterAnnotation) {
|
||||||
|
SharedPdfAndroidHighlightColors.nearestArgb(argb)
|
||||||
|
} else {
|
||||||
|
argb
|
||||||
|
}
|
||||||
|
onUpdate(annotation.copy(colorArgb = nextColor))
|
||||||
|
},
|
||||||
|
color = Color(argb),
|
||||||
|
shape = RoundedCornerShape(13.dp),
|
||||||
|
content = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isHighlighterAnnotation) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.clip(RoundedCornerShape(15.dp))
|
||||||
|
.background(
|
||||||
|
Brush.sweepGradient(
|
||||||
|
listOf(
|
||||||
|
Color.Red,
|
||||||
|
Color.Yellow,
|
||||||
|
Color.Green,
|
||||||
|
Color.Cyan,
|
||||||
|
Color.Blue,
|
||||||
|
Color.Magenta,
|
||||||
|
Color.Red
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp))
|
||||||
|
.clickable {
|
||||||
|
editingHighlighterSlot = highlighterColors
|
||||||
|
.indexOf(annotation.colorArgb)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = annotation.note.orEmpty(),
|
||||||
|
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
|
||||||
|
label = { Text("Note") },
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
selectionKey = annotation.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (annotation.kind == PdfAnnotationKind.INK) {
|
||||||
|
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
|
||||||
|
val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||||
|
Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Slider(
|
||||||
|
value = strokeValue,
|
||||||
|
onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) },
|
||||||
|
valueRange = strokeRange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = onDelete) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editingHighlighterSlot?.let { requestedSlot ->
|
||||||
|
val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex)
|
||||||
|
val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f)
|
||||||
|
SharedHsvColorPickerDialog(
|
||||||
|
initialColor = initialColor,
|
||||||
|
title = "Highlight color ${slot + 1}",
|
||||||
|
onDismiss = { editingHighlighterSlot = null },
|
||||||
|
onSave = { color ->
|
||||||
|
val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||||
|
val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb)
|
||||||
|
onHighlighterPaletteChange(
|
||||||
|
SharedPdfHighlighterPalette(highlighterColors).withColorAt(
|
||||||
|
slotIndex = slot,
|
||||||
|
colorArgb = nextArgb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onUpdate(annotation.copy(colorArgb = syncedArgb))
|
||||||
|
editingHighlighterSlot = null
|
||||||
|
}
|
||||||
|
) { liveColor ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
highlighterColors.forEachIndexed { index, argb ->
|
||||||
|
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(42.dp)
|
||||||
|
.clip(RoundedCornerShape(21.dp))
|
||||||
|
.background(color)
|
||||||
|
.border(
|
||||||
|
width = if (index == slot) 3.dp else 1.dp,
|
||||||
|
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||||
|
shape = RoundedCornerShape(21.dp)
|
||||||
|
)
|
||||||
|
.clickable { editingHighlighterSlot = index },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}",
|
||||||
|
color = if (color.luminance() > 0.5f) Color.Black else Color.White,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopBottomSheetToolButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfEmbeddedAnnotationPanel(
|
||||||
|
annotation: SharedPdfEmbeddedAnnotation,
|
||||||
|
onCopy: () -> Unit,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Embedded PDF comment",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClose) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
DesktopPdfEmbeddedComment(
|
||||||
|
author = annotation.author,
|
||||||
|
contents = annotation.contents.ifBlank { "No comment" },
|
||||||
|
depth = 0
|
||||||
|
)
|
||||||
|
DesktopPdfEmbeddedReplies(annotation.replies, depth = 1)
|
||||||
|
TextButton(onClick = onCopy) {
|
||||||
|
Text("Copy thread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopPdfEmbeddedReplies(
|
||||||
|
replies: List<SharedPdfEmbeddedAnnotation>,
|
||||||
|
depth: Int
|
||||||
|
) {
|
||||||
|
replies.forEach { reply ->
|
||||||
|
HorizontalDivider()
|
||||||
|
DesktopPdfEmbeddedComment(
|
||||||
|
author = reply.author,
|
||||||
|
contents = reply.contents,
|
||||||
|
depth = depth
|
||||||
|
)
|
||||||
|
if (reply.replies.isNotEmpty()) {
|
||||||
|
DesktopPdfEmbeddedReplies(reply.replies, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopPdfEmbeddedComment(
|
||||||
|
author: String,
|
||||||
|
contents: String,
|
||||||
|
depth: Int
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = (depth * 12).dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
author.ifBlank { "Unknown" },
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
contents.ifBlank { "No comment" },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPdfAnnotation.desktopLabel(): String {
|
||||||
|
return when (kind) {
|
||||||
|
PdfAnnotationKind.HIGHLIGHT -> "highlight"
|
||||||
|
PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ')
|
||||||
|
PdfAnnotationKind.TEXT -> "text note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPdfAnnotation.desktopSheetTitle(): String {
|
||||||
|
return when (kind) {
|
||||||
|
PdfAnnotationKind.HIGHLIGHT -> "Highlight"
|
||||||
|
PdfAnnotationKind.INK -> "Annotation"
|
||||||
|
PdfAnnotationKind.TEXT -> "Text note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPdfEmbeddedAnnotation.threadText(): String {
|
||||||
|
return buildString {
|
||||||
|
append(author.ifBlank { "Unknown" })
|
||||||
|
append(": ")
|
||||||
|
appendLine(contents.ifBlank { "No comment" })
|
||||||
|
fun appendReplies(replies: List<SharedPdfEmbeddedAnnotation>, indent: String) {
|
||||||
|
replies.forEach { reply ->
|
||||||
|
append(indent)
|
||||||
|
append(reply.author.ifBlank { "Unknown" })
|
||||||
|
append(": ")
|
||||||
|
appendLine(reply.contents.ifBlank { "No comment" })
|
||||||
|
appendReplies(reply.replies, "$indent ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendReplies(replies, " ")
|
||||||
|
}.trimEnd()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.ImageShader
|
||||||
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
|
import androidx.compose.ui.graphics.TileMode
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||||
|
import com.aryan.reader.shared.PdfDisplayMode
|
||||||
|
import com.aryan.reader.shared.ReaderTheme
|
||||||
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
|
||||||
|
internal enum class DesktopPdfInspectorTab(val title: String) {
|
||||||
|
VIEW("View"),
|
||||||
|
MARKUP("Markup"),
|
||||||
|
ASSIST("Assist")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopPdfThemeStyle(
|
||||||
|
val theme: ReaderTheme,
|
||||||
|
val viewerBackgroundColor: Color,
|
||||||
|
val pageBackgroundColor: Color,
|
||||||
|
val colorFilter: ColorFilter?,
|
||||||
|
val textureBitmap: ImageBitmap?,
|
||||||
|
val textureAlpha: Float,
|
||||||
|
val textureBlendMode: BlendMode
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfThemedPageImage(
|
||||||
|
bitmap: ImageBitmap,
|
||||||
|
contentDescription: String,
|
||||||
|
themeStyle: DesktopPdfThemeStyle,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
colorFilter = themeStyle.colorFilter,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
val textureBitmap = themeStyle.textureBitmap
|
||||||
|
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawRect(
|
||||||
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||||
|
size = size,
|
||||||
|
blendMode = themeStyle.textureBlendMode,
|
||||||
|
alpha = themeStyle.textureAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings {
|
||||||
|
val defaults = ReaderSettings(themeId = "no_theme")
|
||||||
|
val settings = this ?: defaults
|
||||||
|
val themeId = settings.themeId
|
||||||
|
val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId }
|
||||||
|
val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null
|
||||||
|
return settings.copy(
|
||||||
|
themeId = when {
|
||||||
|
themeId == null -> "no_theme"
|
||||||
|
hasPdfTheme || hasCustomColors -> themeId
|
||||||
|
else -> "no_theme"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle {
|
||||||
|
val theme = toDesktopPdfTheme()
|
||||||
|
val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode)
|
||||||
|
val isDarkTexture = theme.isDark || theme.id == "reverse"
|
||||||
|
return DesktopPdfThemeStyle(
|
||||||
|
theme = theme,
|
||||||
|
viewerBackgroundColor = pageBackground,
|
||||||
|
pageBackgroundColor = pageBackground,
|
||||||
|
colorFilter = theme.toDesktopPdfColorFilter(),
|
||||||
|
textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId),
|
||||||
|
textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f),
|
||||||
|
textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme {
|
||||||
|
BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it }
|
||||||
|
val background = backgroundColorArgb?.toComposeColor()
|
||||||
|
val text = textColorArgb?.toComposeColor()
|
||||||
|
return if (background != null && text != null) {
|
||||||
|
ReaderTheme(
|
||||||
|
id = themeId ?: "desktop_pdf_custom",
|
||||||
|
name = "Custom",
|
||||||
|
backgroundColor = background,
|
||||||
|
textColor = text,
|
||||||
|
isDark = darkMode,
|
||||||
|
textureId = textureId,
|
||||||
|
isCustom = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BuiltInPdfReaderThemes.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? {
|
||||||
|
return when (id) {
|
||||||
|
"no_theme", "system" -> null
|
||||||
|
"reverse" -> {
|
||||||
|
val colorMatrix = floatArrayOf(
|
||||||
|
-1f, 0f, 0f, 0f, 255f,
|
||||||
|
0f, -1f, 0f, 0f, 255f,
|
||||||
|
0f, 0f, -1f, 0f, 255f,
|
||||||
|
0f, 0f, 0f, 1f, 0f
|
||||||
|
)
|
||||||
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (!backgroundColor.isSpecified || !textColor.isSpecified) return null
|
||||||
|
val bgR = backgroundColor.red * 255f
|
||||||
|
val bgG = backgroundColor.green * 255f
|
||||||
|
val bgB = backgroundColor.blue * 255f
|
||||||
|
val fgR = textColor.red * 255f
|
||||||
|
val fgG = textColor.green * 255f
|
||||||
|
val fgB = textColor.blue * 255f
|
||||||
|
val dr = (bgR - fgR) / 255f
|
||||||
|
val dg = (bgG - fgG) / 255f
|
||||||
|
val db = (bgB - fgB) / 255f
|
||||||
|
val lumR = 0.2126f
|
||||||
|
val lumG = 0.7152f
|
||||||
|
val lumB = 0.0722f
|
||||||
|
val colorMatrix = floatArrayOf(
|
||||||
|
dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
|
||||||
|
dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
|
||||||
|
db * lumR, db * lumG, db * lumB, 0f, fgB,
|
||||||
|
0f, 0f, 0f, 1f, 0f
|
||||||
|
)
|
||||||
|
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfInspectorSection(
|
||||||
|
title: String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfVisualOptionSwitch(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.toComposeColor(): Color {
|
||||||
|
return Color(this and 0xFFFFFFFFL)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material.icons.filled.ZoomOut
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.aryan.reader.shared.SearchHighlightMode
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
|
||||||
|
import com.aryan.reader.shared.ui.ReaderMinimalSlider
|
||||||
|
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfFullscreenBottomChrome(
|
||||||
|
pageIndex: Int,
|
||||||
|
pageCount: Int,
|
||||||
|
showJumpHistory: Boolean,
|
||||||
|
jumpBackPage: Int?,
|
||||||
|
jumpForwardPage: Int?,
|
||||||
|
onPrevious: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onPageScrub: (Float) -> Unit,
|
||||||
|
onPageScrubFinished: () -> Unit,
|
||||||
|
onJumpBack: () -> Unit,
|
||||||
|
onJumpForward: () -> Unit,
|
||||||
|
onClearJumpHistory: () -> Unit
|
||||||
|
) {
|
||||||
|
val chromeBackground = MaterialTheme.colorScheme.surface
|
||||||
|
val chromeContent = MaterialTheme.colorScheme.onSurface
|
||||||
|
val sliderActive = MaterialTheme.colorScheme.primary
|
||||||
|
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp),
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp),
|
||||||
|
color = chromeBackground,
|
||||||
|
contentColor = chromeContent,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 1.dp,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null
|
||||||
|
DesktopPdfJumpHistoryControls(
|
||||||
|
visible = showJumpHistory,
|
||||||
|
backPage = jumpBackPage,
|
||||||
|
forwardPage = jumpForwardPage,
|
||||||
|
onBack = onJumpBack,
|
||||||
|
onForward = onJumpForward,
|
||||||
|
onClear = onClearJumpHistory
|
||||||
|
)
|
||||||
|
if (showJumpHistory && hasJumpTargets) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val canGoPrevious = pageIndex > 0
|
||||||
|
val canGoNext = pageIndex < pageCount - 1
|
||||||
|
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||||
|
contentDescription = "Previous page",
|
||||||
|
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ReaderMinimalSlider(
|
||||||
|
value = pageIndex.toFloat(),
|
||||||
|
onValueChange = onPageScrub,
|
||||||
|
onValueChangeFinished = onPageScrubFinished,
|
||||||
|
valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(),
|
||||||
|
enabled = pageCount > 1,
|
||||||
|
activeColor = sliderActive,
|
||||||
|
inactiveColor = sliderInactive,
|
||||||
|
thumbColor = sliderActive,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = onNext, enabled = canGoNext) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateNext,
|
||||||
|
contentDescription = "Next page",
|
||||||
|
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfZoomPercentageIndicator(
|
||||||
|
percentage: Int,
|
||||||
|
onResetZoomClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$percentage%",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.height(16.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ZoomOut,
|
||||||
|
contentDescription = "Reset zoom",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onResetZoomClick)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfSearchTopBar(
|
||||||
|
query: String,
|
||||||
|
showResultsPanel: Boolean,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onToggleResults: () -> Unit
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(80)
|
||||||
|
runCatching { focusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Search in PDF") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||||
|
trailingIcon = if (query.isNotEmpty()) {
|
||||||
|
{
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
selectionKey = "desktop-pdf-search"
|
||||||
|
)
|
||||||
|
IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = if (showResultsPanel) "Hide search results" else "Show search results"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BoxScope.DesktopPdfSearchOverlay(
|
||||||
|
isSearchActive: Boolean,
|
||||||
|
showResultsPanel: Boolean,
|
||||||
|
query: String,
|
||||||
|
results: List<SharedPdfSearchResult>,
|
||||||
|
activeSearchIndex: Int,
|
||||||
|
highlightMode: SearchHighlightMode,
|
||||||
|
isIndexing: Boolean,
|
||||||
|
indexedPageCount: Int,
|
||||||
|
pageCount: Int,
|
||||||
|
onResultClick: (Int) -> Unit,
|
||||||
|
onShowResults: () -> Unit,
|
||||||
|
onPrevious: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onToggleHighlightMode: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isSearchActive && showResultsPanel,
|
||||||
|
enter = slideInVertically { -it } + fadeIn(),
|
||||||
|
exit = slideOutVertically { -it } + fadeOut(),
|
||||||
|
modifier = Modifier.fillMaxSize().zIndex(30f)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
if (isIndexing) {
|
||||||
|
val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat()
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||||
|
Text(
|
||||||
|
"Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
query.isBlank() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
if (isIndexing) "No matches in indexed pages yet" else "No matches",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
isIndexing -> "${results.size} matches so far"
|
||||||
|
else -> "${results.size} matches"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = results,
|
||||||
|
key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" }
|
||||||
|
) { index, result ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) },
|
||||||
|
color = if (index == activeSearchIndex) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Page ${result.pageIndex + 1}",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
result.preview,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isSearchActive && !showResultsPanel && results.isNotEmpty(),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 18.dp)
|
||||||
|
.zIndex(31f)
|
||||||
|
) {
|
||||||
|
DesktopPdfSearchNavigationPill(
|
||||||
|
activeSearchIndex = activeSearchIndex,
|
||||||
|
resultCount = results.size,
|
||||||
|
highlightMode = highlightMode,
|
||||||
|
onShowResults = onShowResults,
|
||||||
|
onPrevious = onPrevious,
|
||||||
|
onNext = onNext,
|
||||||
|
onToggleHighlightMode = onToggleHighlightMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopPdfSearchNavigationPill(
|
||||||
|
activeSearchIndex: Int,
|
||||||
|
resultCount: Int,
|
||||||
|
highlightMode: SearchHighlightMode,
|
||||||
|
onShowResults: () -> Unit,
|
||||||
|
onPrevious: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onToggleHighlightMode: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
shadowElevation = 8.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = "Toggle search highlights",
|
||||||
|
tint = if (highlightMode == SearchHighlightMode.ALL) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (activeSearchIndex in 0 until resultCount) {
|
||||||
|
"${activeSearchIndex + 1}/$resultCount"
|
||||||
|
} else {
|
||||||
|
"$resultCount matches"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.PdfTocEntry
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfJumpHistoryControls(
|
||||||
|
visible: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
backPage: Int?,
|
||||||
|
forwardPage: Int?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onForward: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
val hasJumpTargets = backPage != null || forwardPage != null
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && hasJumpTargets,
|
||||||
|
enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(),
|
||||||
|
exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(),
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onBack,
|
||||||
|
enabled = backPage != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Jump back",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
backPage?.let { "P. ${it + 1}" } ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onClear,
|
||||||
|
modifier = Modifier.weight(0.8f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Clear jump history",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onForward,
|
||||||
|
enabled = forwardPage != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
forwardPage?.let { "P. ${it + 1}" } ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = "Jump forward",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfTocParentIndices(toc: List<PdfTocEntry>): Set<Int> {
|
||||||
|
return toc.indices.filter { index ->
|
||||||
|
val next = toc.getOrNull(index + 1)
|
||||||
|
next != null && next.nestLevel > toc[index].nestLevel
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfTocAncestorIndices(
|
||||||
|
toc: List<PdfTocEntry>,
|
||||||
|
originalIndex: Int
|
||||||
|
): Set<Int> {
|
||||||
|
val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet()
|
||||||
|
val ancestors = mutableSetOf<Int>()
|
||||||
|
var currentDepth = targetDepth
|
||||||
|
for (index in originalIndex downTo 0) {
|
||||||
|
val entry = toc[index]
|
||||||
|
if (entry.nestLevel < currentDepth) {
|
||||||
|
ancestors += index
|
||||||
|
currentDepth = entry.nestLevel
|
||||||
|
}
|
||||||
|
if (currentDepth == 0) break
|
||||||
|
}
|
||||||
|
return ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopVisiblePdfTocEntries(
|
||||||
|
toc: List<PdfTocEntry>,
|
||||||
|
expandedIndices: Set<Int>
|
||||||
|
): List<Pair<Int, PdfTocEntry>> {
|
||||||
|
val result = mutableListOf<Pair<Int, PdfTocEntry>>()
|
||||||
|
val visibilityStack = BooleanArray(50) { false }
|
||||||
|
visibilityStack[0] = true
|
||||||
|
|
||||||
|
toc.forEachIndexed { index, entry ->
|
||||||
|
val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex)
|
||||||
|
if (visibilityStack[depth]) {
|
||||||
|
result += index to entry
|
||||||
|
if (depth + 1 < visibilityStack.size) {
|
||||||
|
visibilityStack[depth + 1] = index in expandedIndices
|
||||||
|
}
|
||||||
|
} else if (depth + 1 < visibilityStack.size) {
|
||||||
|
visibilityStack[depth + 1] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfTocTreeItem(
|
||||||
|
entry: PdfTocEntry,
|
||||||
|
selected: Boolean,
|
||||||
|
hasChildren: Boolean,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
onToggleExpand: () -> Unit,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { onClick() }
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 46.dp)
|
||||||
|
.padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clickable(enabled = hasChildren) { onToggleExpand() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (hasChildren) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
entry.title,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"p. ${entry.pageIndex + 1}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfNavigationEmpty(message: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfThumbnailTile(
|
||||||
|
document: DesktopPdfDocument,
|
||||||
|
pageIndex: Int,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val documentHandleId = document.handleId
|
||||||
|
var thumbnail by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
||||||
|
var renderFailed by remember(documentHandleId, pageIndex) { mutableStateOf(false) }
|
||||||
|
val pageSize = document.pageSizes.getOrNull(pageIndex)
|
||||||
|
val thumbnailScale = remember(pageSize) {
|
||||||
|
val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f
|
||||||
|
(120f / width).coerceIn(0.08f, 0.35f)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(documentHandleId, pageIndex, thumbnailScale) {
|
||||||
|
thumbnail = null
|
||||||
|
renderFailed = false
|
||||||
|
val rendered = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
DesktopPdfium.renderPage(
|
||||||
|
document = document,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
scale = thumbnailScale,
|
||||||
|
renderAnnotations = false
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
thumbnail = rendered
|
||||||
|
renderFailed = rendered == null
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = if (selected) 2.dp else 1.dp,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
val render = thumbnail
|
||||||
|
if (render != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = render.image,
|
||||||
|
contentDescription = "Page ${pageIndex + 1}",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(3.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
if (renderFailed) "!" else "${pageIndex + 1}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${pageIndex + 1}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfPageScrubOverlay(
|
||||||
|
pageIndex: Int?,
|
||||||
|
pageCount: Int
|
||||||
|
) {
|
||||||
|
if (pageIndex == null || pageCount <= 0) return
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
shadowElevation = 8.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Page ${pageIndex + 1} of $pageCount",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||||
|
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||||
|
import com.aryan.reader.shared.pdf.PdfNormalizedPoint
|
||||||
|
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||||
|
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||||
|
import com.aryan.reader.shared.pdf.PdfSelectionGeometry
|
||||||
|
import com.aryan.reader.shared.pdf.PdfTextCharBounds
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
|
||||||
|
import com.aryan.reader.shared.ui.sharedPdfHitTest
|
||||||
|
import com.aryan.reader.shared.ui.toSharedPdfPoint
|
||||||
|
|
||||||
|
internal val PdfInkTool.isDesktopHighlighter: Boolean
|
||||||
|
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||||
|
|
||||||
|
internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean
|
||||||
|
get() = kind == PdfAnnotationKind.HIGHLIGHT &&
|
||||||
|
text.isNotBlank() &&
|
||||||
|
rangeStartIndex != null &&
|
||||||
|
rangeEndIndex != null
|
||||||
|
|
||||||
|
internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize,
|
||||||
|
tool: PdfInkTool,
|
||||||
|
snapHighlighter: Boolean,
|
||||||
|
timestamp: Long
|
||||||
|
): List<PdfPagePoint> {
|
||||||
|
val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp)
|
||||||
|
if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) {
|
||||||
|
val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat()
|
||||||
|
return listOf(
|
||||||
|
first(),
|
||||||
|
SharedPdfInkRenderer.calculateSnappedPoint(
|
||||||
|
currentPoint = nextPoint,
|
||||||
|
startPoint = first(),
|
||||||
|
pageAspectRatio = pageAspectRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this + nextPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopPdfCharHit(
|
||||||
|
val index: Int,
|
||||||
|
val source: String,
|
||||||
|
val point: Offset,
|
||||||
|
val normalized: PdfNormalizedPoint
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection {
|
||||||
|
return DesktopPdfTextSelection(
|
||||||
|
text = text,
|
||||||
|
lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) },
|
||||||
|
startIndex = rangeStartIndex ?: 0,
|
||||||
|
endIndex = rangeEndIndex ?: text.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfDocument.linkAt(
|
||||||
|
pageIndex: Int,
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): DesktopPdfLinkTarget? {
|
||||||
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||||
|
return DesktopPdfium.linkAt(
|
||||||
|
document = this,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
normalizedX = point.x / canvasSize.width,
|
||||||
|
normalizedY = point.y / canvasSize.height,
|
||||||
|
viewportWidth = canvasSize.width,
|
||||||
|
viewportHeight = canvasSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfDocument.charHitAt(
|
||||||
|
pageIndex: Int,
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): DesktopPdfCharHit? {
|
||||||
|
val normalized = PdfSelectionGeometry.normalizedPoint(
|
||||||
|
pointX = point.x,
|
||||||
|
pointY = point.y,
|
||||||
|
viewportWidth = canvasSize.width,
|
||||||
|
viewportHeight = canvasSize.height
|
||||||
|
) ?: return null
|
||||||
|
val nativeIndex = DesktopPdfium.charIndexAt(
|
||||||
|
document = this,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
normalizedX = normalized.x,
|
||||||
|
normalizedY = normalized.y,
|
||||||
|
viewportWidth = canvasSize.width,
|
||||||
|
viewportHeight = canvasSize.height
|
||||||
|
)
|
||||||
|
if (nativeIndex != null) {
|
||||||
|
return DesktopPdfCharHit(
|
||||||
|
index = nativeIndex,
|
||||||
|
source = "native",
|
||||||
|
point = point,
|
||||||
|
normalized = normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val fallback = PdfSelectionGeometry.nearestCharOnLine(
|
||||||
|
chars = textPageData(pageIndex).chars.visiblePdfTextBounds(),
|
||||||
|
point = normalized
|
||||||
|
) ?: return null
|
||||||
|
return DesktopPdfCharHit(
|
||||||
|
index = fallback.index,
|
||||||
|
source = "fallback_line",
|
||||||
|
point = point,
|
||||||
|
normalized = normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfDocument.wordSelectionAt(
|
||||||
|
pageIndex: Int,
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): DesktopPdfTextSelection? {
|
||||||
|
val hit = charHitAt(pageIndex, point, canvasSize) ?: return null
|
||||||
|
if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val pageText = textPageData(pageIndex).text
|
||||||
|
if (pageText.isEmpty()) return null
|
||||||
|
val hitIndex = hit.index.coerceIn(0, pageText.lastIndex)
|
||||||
|
if (!pageText[hitIndex].isDesktopPdfWordPart()) return null
|
||||||
|
var startIndex = hitIndex
|
||||||
|
while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) {
|
||||||
|
startIndex -= 1
|
||||||
|
}
|
||||||
|
var endIndex = hitIndex
|
||||||
|
while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) {
|
||||||
|
endIndex += 1
|
||||||
|
}
|
||||||
|
return selectionBetweenIndexes(
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startIndex = startIndex,
|
||||||
|
endIndex = endIndex,
|
||||||
|
canvasSize = canvasSize,
|
||||||
|
useNativeBounds = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopPdfDocument.isPointNearTextChar(
|
||||||
|
pageIndex: Int,
|
||||||
|
charIndex: Int,
|
||||||
|
point: PdfNormalizedPoint
|
||||||
|
): Boolean {
|
||||||
|
val charBounds = textPageData(pageIndex).chars
|
||||||
|
.visiblePdfTextBounds()
|
||||||
|
.firstOrNull { it.index == charIndex }
|
||||||
|
?: return false
|
||||||
|
val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f)
|
||||||
|
val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f)
|
||||||
|
return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) &&
|
||||||
|
point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.isDesktopPdfWordPart(): Boolean {
|
||||||
|
return isLetterOrDigit() || this == '\'' || this == '-' || this == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfDocument.selectionPreviewBetweenIndexes(
|
||||||
|
pageIndex: Int,
|
||||||
|
startIndex: Int,
|
||||||
|
endIndex: Int,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): DesktopPdfTextSelection? {
|
||||||
|
return selectionBetweenIndexes(
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startIndex = startIndex,
|
||||||
|
endIndex = endIndex,
|
||||||
|
canvasSize = canvasSize,
|
||||||
|
useNativeBounds = false,
|
||||||
|
includeText = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfDocument.selectionBetweenIndexes(
|
||||||
|
pageIndex: Int,
|
||||||
|
startIndex: Int,
|
||||||
|
endIndex: Int,
|
||||||
|
canvasSize: IntSize,
|
||||||
|
useNativeBounds: Boolean = true,
|
||||||
|
includeText: Boolean = true
|
||||||
|
): DesktopPdfTextSelection? {
|
||||||
|
val chars = textPageData(pageIndex).chars
|
||||||
|
if (chars.isEmpty()) return null
|
||||||
|
val firstIndex = minOf(startIndex, endIndex)
|
||||||
|
val lastIndex = maxOf(startIndex, endIndex)
|
||||||
|
val selectedChars = chars.filter { it.index in firstIndex..lastIndex }
|
||||||
|
if (selectedChars.isEmpty()) return null
|
||||||
|
val text = if (includeText) {
|
||||||
|
selectedChars.joinToString("") { it.char.toString() }
|
||||||
|
.replace(DesktopPdfSelectionInlineWhitespaceRegex, " ")
|
||||||
|
.replace(DesktopPdfSelectionBlankLinesRegex, "\n\n")
|
||||||
|
.trim()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (includeText && text.isBlank()) return null
|
||||||
|
val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds())
|
||||||
|
if (!includeText && fallbackBounds.isEmpty()) return null
|
||||||
|
val nativeBounds = if (useNativeBounds) {
|
||||||
|
DesktopPdfium.textRectsForRange(
|
||||||
|
document = this,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startIndex = firstIndex,
|
||||||
|
endIndex = lastIndex,
|
||||||
|
viewportWidth = canvasSize.width,
|
||||||
|
viewportHeight = canvasSize.height
|
||||||
|
).map { it.toPdfPageBounds() }
|
||||||
|
.filter { it.right > it.left && it.bottom > it.top }
|
||||||
|
.mergePdfBoundsByLine()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
return DesktopPdfTextSelection(
|
||||||
|
text = text,
|
||||||
|
lineBounds = nativeBounds.ifEmpty { fallbackBounds },
|
||||||
|
startIndex = firstIndex,
|
||||||
|
endIndex = lastIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds {
|
||||||
|
return PdfPageBounds(
|
||||||
|
left = left,
|
||||||
|
top = top,
|
||||||
|
right = right,
|
||||||
|
bottom = bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPdfAnnotation.toRenderablePdfAnnotations(
|
||||||
|
document: DesktopPdfDocument,
|
||||||
|
pageIndex: Int,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): List<SharedPdfAnnotation> {
|
||||||
|
val startIndex = rangeStartIndex
|
||||||
|
val endIndex = rangeEndIndex
|
||||||
|
if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) {
|
||||||
|
return listOf(this)
|
||||||
|
}
|
||||||
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) {
|
||||||
|
return listOf(this)
|
||||||
|
}
|
||||||
|
val dynamicBounds = DesktopPdfium.textRectsForRange(
|
||||||
|
document = document,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startIndex = startIndex,
|
||||||
|
endIndex = endIndex,
|
||||||
|
viewportWidth = canvasSize.width,
|
||||||
|
viewportHeight = canvasSize.height
|
||||||
|
).map { it.toPdfPageBounds() }
|
||||||
|
.filter { it.right > it.left && it.bottom > it.top }
|
||||||
|
.mergePdfBoundsByLine()
|
||||||
|
|
||||||
|
return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } }
|
||||||
|
.mapIndexed { index, dynamicBounds ->
|
||||||
|
copy(
|
||||||
|
id = "${id}_line_$index",
|
||||||
|
bounds = dynamicBounds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPdfTextDraft.containsOffset(
|
||||||
|
pageIndex: Int,
|
||||||
|
offset: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): Boolean {
|
||||||
|
if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false
|
||||||
|
val left = bounds.left * canvasSize.width
|
||||||
|
val right = bounds.right * canvasSize.width
|
||||||
|
val top = bounds.top * canvasSize.height
|
||||||
|
val bottom = bounds.bottom * canvasSize.height
|
||||||
|
return offset.x in left..right && offset.y in top..bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<SharedPdfAnnotation>.textAnnotationHitAt(
|
||||||
|
pageIndex: Int,
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): SharedPdfAnnotation? {
|
||||||
|
return asReversed().firstOrNull { annotation ->
|
||||||
|
annotation.kind == PdfAnnotationKind.TEXT &&
|
||||||
|
annotation.pageIndex == pageIndex &&
|
||||||
|
annotation.sharedPdfHitTest(point, canvasSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<PdfPageBounds>.mergePdfBoundsByLine(): List<PdfPageBounds> {
|
||||||
|
return PdfSelectionGeometry.mergeBoundsByLine(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<DesktopPdfTextChar>.visiblePdfTextBounds(): List<PdfTextCharBounds> {
|
||||||
|
return asSequence()
|
||||||
|
.filter { it.hasBounds && !it.char.isISOControl() }
|
||||||
|
.map { it.toPdfTextCharBounds() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds {
|
||||||
|
return PdfTextCharBounds(
|
||||||
|
index = index,
|
||||||
|
left = left,
|
||||||
|
top = top,
|
||||||
|
right = right,
|
||||||
|
bottom = bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L
|
||||||
|
internal const val DesktopPdfZoomCommitDebounceMillis = 180L
|
||||||
|
internal const val DesktopPdfZoomRenderDebounceMillis = 300L
|
||||||
|
internal const val DesktopPdfViewportPersistDebounceMillis = 300L
|
||||||
|
internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L
|
||||||
|
internal const val DesktopPdfRenderScaleTolerance = 0.01f
|
||||||
|
internal const val DesktopPdfPaginationRenderCacheRadius = 2
|
||||||
|
private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+")
|
||||||
|
private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}")
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.PathParser
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||||
|
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||||
|
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||||
|
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal data class DesktopPdfTextSelection(
|
||||||
|
val text: String,
|
||||||
|
val lineBounds: List<PdfPageBounds>,
|
||||||
|
val startIndex: Int,
|
||||||
|
val endIndex: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class DesktopPdfSelectionCanvasBounds(
|
||||||
|
val left: Float,
|
||||||
|
val top: Float,
|
||||||
|
val right: Float,
|
||||||
|
val bottom: Float
|
||||||
|
) {
|
||||||
|
val centerX: Float get() = (left + right) / 2f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? {
|
||||||
|
val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top }
|
||||||
|
if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||||
|
return DesktopPdfSelectionCanvasBounds(
|
||||||
|
left = validBounds.minOf { it.left } * canvasSize.width,
|
||||||
|
top = validBounds.minOf { it.top } * canvasSize.height,
|
||||||
|
right = validBounds.maxOf { it.right } * canvasSize.width,
|
||||||
|
bottom = validBounds.maxOf { it.bottom } * canvasSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfTextSelection.menuAnchor(
|
||||||
|
canvasSize: IntSize,
|
||||||
|
fallback: Offset?
|
||||||
|
): Offset {
|
||||||
|
val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero
|
||||||
|
return Offset(x = bounds.centerX, y = bounds.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? {
|
||||||
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||||
|
val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null
|
||||||
|
return Offset(
|
||||||
|
x = first.left * canvasSize.width,
|
||||||
|
y = first.bottom * canvasSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? {
|
||||||
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||||
|
val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null
|
||||||
|
return Offset(
|
||||||
|
x = last.right * canvasSize.width,
|
||||||
|
y = last.bottom * canvasSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfTextSelection.handleAt(
|
||||||
|
point: Offset,
|
||||||
|
canvasSize: IntSize
|
||||||
|
): DesktopPdfSelectionHandle? {
|
||||||
|
val start = startHandleOffset(canvasSize)
|
||||||
|
val end = endHandleOffset(canvasSize)
|
||||||
|
|
||||||
|
fun Offset.containsHandlePoint(): Boolean {
|
||||||
|
val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f
|
||||||
|
return point.x in (x - halfWidth)..(x + halfWidth) &&
|
||||||
|
point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START
|
||||||
|
end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PdfSearchHighlightOverlay(
|
||||||
|
bounds: List<PdfPageBounds>,
|
||||||
|
canvasSize: IntSize,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
bounds.forEach { rect ->
|
||||||
|
drawRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
||||||
|
size = androidx.compose.ui.geometry.Size(
|
||||||
|
(rect.right - rect.left) * canvasSize.width,
|
||||||
|
(rect.bottom - rect.top) * canvasSize.height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PdfTextSelectionOverlay(
|
||||||
|
selection: DesktopPdfTextSelection?,
|
||||||
|
canvasSize: IntSize
|
||||||
|
) {
|
||||||
|
val bounds = selection?.lineBounds.orEmpty()
|
||||||
|
if (bounds.isEmpty()) return
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
bounds.forEach { rect ->
|
||||||
|
drawRect(
|
||||||
|
color = Color(0x663B82F6),
|
||||||
|
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
||||||
|
size = androidx.compose.ui.geometry.Size(
|
||||||
|
(rect.right - rect.left) * canvasSize.width,
|
||||||
|
(rect.bottom - rect.top) * canvasSize.height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PdfTextSelectionHandles(
|
||||||
|
selection: DesktopPdfTextSelection?,
|
||||||
|
canvasSize: IntSize,
|
||||||
|
activeHandle: DesktopPdfSelectionHandle?
|
||||||
|
) {
|
||||||
|
selection ?: return
|
||||||
|
if (canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val handleSize = 24.dp
|
||||||
|
val handleWidthPx = with(density) { handleSize.toPx() }
|
||||||
|
val start = selection.startHandleOffset(canvasSize)
|
||||||
|
val end = selection.endHandleOffset(canvasSize)
|
||||||
|
val handleColor = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
fun Modifier.handleOffset(position: Offset): Modifier = offset {
|
||||||
|
IntOffset(
|
||||||
|
x = (position.x - handleWidthPx / 2f).roundToInt(),
|
||||||
|
y = position.y.roundToInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
start?.let { position ->
|
||||||
|
Icon(
|
||||||
|
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
|
||||||
|
contentDescription = "Selection start handle",
|
||||||
|
tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.handleOffset(position)
|
||||||
|
.size(handleSize)
|
||||||
|
.graphicsLayer {
|
||||||
|
rotationZ = 30f
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
end?.let { position ->
|
||||||
|
Icon(
|
||||||
|
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
|
||||||
|
contentDescription = "Selection end handle",
|
||||||
|
tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.handleOffset(position)
|
||||||
|
.size(handleSize)
|
||||||
|
.graphicsLayer {
|
||||||
|
rotationZ = -30f
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DesktopPdfSelectionMenuIcons {
|
||||||
|
val Copy = vector(
|
||||||
|
name = "DesktopPdfSelectionCopy",
|
||||||
|
pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z"
|
||||||
|
)
|
||||||
|
val Dictionary = vector(
|
||||||
|
name = "DesktopPdfSelectionDictionary",
|
||||||
|
pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z"
|
||||||
|
)
|
||||||
|
val Search = vector(
|
||||||
|
name = "DesktopPdfSelectionSearch",
|
||||||
|
pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z"
|
||||||
|
)
|
||||||
|
val Teardrop = vector(
|
||||||
|
name = "DesktopPdfSelectionTeardrop",
|
||||||
|
pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun vector(name: String, pathData: String): ImageVector {
|
||||||
|
return ImageVector.Builder(
|
||||||
|
name = name,
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 960f,
|
||||||
|
viewportHeight = 960f
|
||||||
|
).apply {
|
||||||
|
addPath(
|
||||||
|
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||||
|
fill = SolidColor(Color.Black)
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DesktopPdfSelectionHandle {
|
||||||
|
START,
|
||||||
|
END
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PdfSelectionMenu(
|
||||||
|
selection: DesktopPdfTextSelection?,
|
||||||
|
menuOffset: Offset?,
|
||||||
|
canvasSize: IntSize,
|
||||||
|
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||||
|
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
|
||||||
|
onCopy: () -> Unit,
|
||||||
|
onHighlight: (Int) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
|
onDefine: () -> Unit,
|
||||||
|
onSpeak: () -> Unit,
|
||||||
|
showDefine: Boolean,
|
||||||
|
showSpeak: Boolean,
|
||||||
|
showSearch: Boolean,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
selection ?: return
|
||||||
|
val anchor = menuOffset ?: return
|
||||||
|
val selectionBounds = selection.canvasBounds(canvasSize)
|
||||||
|
val paletteColors = remember(highlighterPalette) {
|
||||||
|
SharedPdfAndroidHighlightColors.palette
|
||||||
|
}
|
||||||
|
var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) {
|
||||||
|
mutableStateOf<Int?>(null)
|
||||||
|
}
|
||||||
|
val actions = buildList {
|
||||||
|
add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy))
|
||||||
|
if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine))
|
||||||
|
if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak))
|
||||||
|
if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch))
|
||||||
|
add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true))
|
||||||
|
}
|
||||||
|
val estimatedHeight = PdfSelectionMenuPaletteHeightPx +
|
||||||
|
(((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx)
|
||||||
|
val placement = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height),
|
||||||
|
popup = SharedSelectionMenuSize(
|
||||||
|
width = PdfSelectionMenuWidthPx.roundToInt(),
|
||||||
|
height = estimatedHeight.roundToInt()
|
||||||
|
),
|
||||||
|
selection = if (selectionBounds != null) {
|
||||||
|
SharedSelectionMenuRect(
|
||||||
|
left = selectionBounds.left,
|
||||||
|
top = selectionBounds.top,
|
||||||
|
right = selectionBounds.right,
|
||||||
|
bottom = selectionBounds.bottom
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SharedSelectionMenuRect(
|
||||||
|
left = anchor.x,
|
||||||
|
top = anchor.y,
|
||||||
|
right = anchor.x,
|
||||||
|
bottom = anchor.y
|
||||||
|
)
|
||||||
|
},
|
||||||
|
marginPx = PdfSelectionMenuMarginPx,
|
||||||
|
gapPx = PdfSelectionMenuAnchorGapPx
|
||||||
|
)
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
modifier = Modifier.offset {
|
||||||
|
IntOffset(placement.x, placement.y)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 180.dp, max = 220.dp)
|
||||||
|
.padding(bottom = 6.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
paletteColors.forEach { colorArgb ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.size(28.dp)
|
||||||
|
.clickable { onHighlight(colorArgb) },
|
||||||
|
color = Color(colorArgb),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)),
|
||||||
|
content = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.size(28.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(
|
||||||
|
Brush.sweepGradient(
|
||||||
|
listOf(
|
||||||
|
Color.Red,
|
||||||
|
Color.Yellow,
|
||||||
|
Color.Green,
|
||||||
|
Color.Cyan,
|
||||||
|
Color.Blue,
|
||||||
|
Color.Magenta,
|
||||||
|
Color.Red
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp))
|
||||||
|
.clickable { editingHighlighterSlot = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
actions.chunked(3).forEach { rowActions ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
rowActions.forEach { action ->
|
||||||
|
val tint = if (action.isDestructive) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(58.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { action.onClick() }
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = action.icon,
|
||||||
|
contentDescription = action.label,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
action.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeat(3 - rowActions.size) {
|
||||||
|
Spacer(modifier = Modifier.width(58.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editingHighlighterSlot?.let { requestedSlot ->
|
||||||
|
val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex)
|
||||||
|
val initialColor = Color(paletteColors[slot]).copy(alpha = 1f)
|
||||||
|
SharedHsvColorPickerDialog(
|
||||||
|
initialColor = initialColor,
|
||||||
|
title = "Highlight color ${slot + 1}",
|
||||||
|
onDismiss = { editingHighlighterSlot = null },
|
||||||
|
onSave = { color ->
|
||||||
|
onHighlighterPaletteChange(
|
||||||
|
SharedPdfHighlighterPalette(paletteColors).withColorAt(
|
||||||
|
slotIndex = slot,
|
||||||
|
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
editingHighlighterSlot = null
|
||||||
|
}
|
||||||
|
) { liveColor ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
paletteColors.forEachIndexed { index, argb ->
|
||||||
|
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(42.dp)
|
||||||
|
.clip(RoundedCornerShape(21.dp))
|
||||||
|
.background(color)
|
||||||
|
.border(
|
||||||
|
width = if (index == slot) 3.dp else 1.dp,
|
||||||
|
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||||
|
shape = RoundedCornerShape(21.dp)
|
||||||
|
)
|
||||||
|
.clickable { editingHighlighterSlot = index },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}",
|
||||||
|
color = if (color.luminance() > 0.5f) Color.Black else Color.White,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PdfSelectionMenuAction(
|
||||||
|
val label: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
val isDestructive: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val PdfSelectionMenuWidthPx = 220f
|
||||||
|
private const val PdfSelectionMenuPaletteHeightPx = 54f
|
||||||
|
private const val PdfSelectionMenuActionRowHeightPx = 66f
|
||||||
|
private const val PdfSelectionMenuAnchorGapPx = 16f
|
||||||
|
private const val PdfSelectionMenuMarginPx = 6f
|
||||||
|
private const val DesktopPdfSelectionHandleTouchWidthPx = 44f
|
||||||
|
private const val DesktopPdfSelectionHandleTouchTopPx = 8f
|
||||||
|
private const val DesktopPdfSelectionHandleTouchBottomPx = 40f
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1"
|
||||||
|
|
||||||
|
internal fun desktopPdfAnnotationFile(documentPath: String): File {
|
||||||
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||||
|
return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfBookmarkFile(documentPath: String): File {
|
||||||
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||||
|
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfRichTextFile(documentPath: String): File {
|
||||||
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||||
|
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfSearchIndexFile(documentPath: String): File {
|
||||||
|
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||||
|
return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int {
|
||||||
|
val sourceFile = File(document.path)
|
||||||
|
val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount()
|
||||||
|
if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0
|
||||||
|
val metadata = lines
|
||||||
|
.asSequence()
|
||||||
|
.drop(1)
|
||||||
|
.takeWhile { !it.startsWith("page\t") }
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val parts = line.split('\t', limit = 2)
|
||||||
|
if (parts.size == 2) parts[0] to parts[1] else null
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
val isFresh = metadata["pathHash"] == document.path.hashCode().toString() &&
|
||||||
|
metadata["fileSize"] == sourceFile.length().toString() &&
|
||||||
|
metadata["lastModified"] == sourceFile.lastModified().toString() &&
|
||||||
|
metadata["pageCount"] == document.pageCount.toString()
|
||||||
|
if (!isFresh) return 0
|
||||||
|
|
||||||
|
val decoder = Base64.getDecoder()
|
||||||
|
lines.asSequence()
|
||||||
|
.filter { it.startsWith("page\t") }
|
||||||
|
.forEach { line ->
|
||||||
|
val parts = line.split('\t', limit = 3)
|
||||||
|
val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach
|
||||||
|
val text = runCatching {
|
||||||
|
String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8)
|
||||||
|
}.getOrDefault("")
|
||||||
|
document.cacheSearchTextPage(pageIndex, text)
|
||||||
|
}
|
||||||
|
return document.indexedSearchTextPageCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) {
|
||||||
|
val sourceFile = File(document.path)
|
||||||
|
val pages = document.indexedSearchPages()
|
||||||
|
if (pages.isEmpty()) return
|
||||||
|
val encoder = Base64.getEncoder()
|
||||||
|
val payload = buildString {
|
||||||
|
appendLine(DesktopPdfSearchIndexHeader)
|
||||||
|
appendLine("pathHash\t${document.path.hashCode()}")
|
||||||
|
appendLine("fileSize\t${sourceFile.length()}")
|
||||||
|
appendLine("lastModified\t${sourceFile.lastModified()}")
|
||||||
|
appendLine("pageCount\t${document.pageCount}")
|
||||||
|
pages.forEach { page ->
|
||||||
|
append("page\t")
|
||||||
|
append(page.pageIndex)
|
||||||
|
append('\t')
|
||||||
|
appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
indexFile.parentFile?.mkdirs()
|
||||||
|
indexFile.writeText(payload, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.calculateCentroid
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import com.aryan.reader.shared.PdfDisplayMode
|
||||||
|
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val DesktopPdfZoomGestureFrameMillis = 16L
|
||||||
|
internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f
|
||||||
|
|
||||||
|
internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float {
|
||||||
|
if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f
|
||||||
|
val normalizedDelta = scrollDelta.coerceIn(-8f, 8f)
|
||||||
|
return exp((-normalizedDelta * 0.12f).toDouble()).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfZoomTarget(
|
||||||
|
currentZoom: Float,
|
||||||
|
zoomSpec: PdfZoomSpec,
|
||||||
|
factor: Float
|
||||||
|
): Float {
|
||||||
|
val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default
|
||||||
|
val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
return zoomSpec.clamp(baseZoom * safeFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfAnchoredScrollTarget(
|
||||||
|
currentScroll: Int,
|
||||||
|
anchor: Float,
|
||||||
|
oldZoom: Float,
|
||||||
|
newZoom: Float
|
||||||
|
): Int {
|
||||||
|
if (
|
||||||
|
!anchor.isFinite() ||
|
||||||
|
!oldZoom.isFinite() ||
|
||||||
|
!newZoom.isFinite() ||
|
||||||
|
oldZoom <= 0f ||
|
||||||
|
newZoom <= 0f
|
||||||
|
) {
|
||||||
|
return currentScroll.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
val zoomRatio = newZoom / oldZoom
|
||||||
|
return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfAnchoredLazyItemScrollOffset(
|
||||||
|
itemOffset: Int,
|
||||||
|
anchor: Float,
|
||||||
|
oldZoom: Float,
|
||||||
|
newZoom: Float
|
||||||
|
): Int {
|
||||||
|
if (
|
||||||
|
!anchor.isFinite() ||
|
||||||
|
!oldZoom.isFinite() ||
|
||||||
|
!newZoom.isFinite() ||
|
||||||
|
oldZoom <= 0f ||
|
||||||
|
newZoom <= 0f
|
||||||
|
) {
|
||||||
|
return (-itemOffset).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
val zoomRatio = newZoom / oldZoom
|
||||||
|
val offsetWithinItem = anchor - itemOffset
|
||||||
|
return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfAnchoredPageScrollDelta(
|
||||||
|
viewportRootOffset: Offset,
|
||||||
|
oldPageRootOffset: Offset,
|
||||||
|
currentPageRootOffset: Offset,
|
||||||
|
anchor: Offset,
|
||||||
|
oldZoom: Float,
|
||||||
|
newZoom: Float
|
||||||
|
): IntOffset? {
|
||||||
|
if (
|
||||||
|
!anchor.x.isFinite() ||
|
||||||
|
!anchor.y.isFinite() ||
|
||||||
|
!oldZoom.isFinite() ||
|
||||||
|
!newZoom.isFinite() ||
|
||||||
|
oldZoom <= 0f ||
|
||||||
|
newZoom <= 0f
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val rootAnchor = viewportRootOffset + anchor
|
||||||
|
val oldPageLocal = rootAnchor - oldPageRootOffset
|
||||||
|
val zoomRatio = newZoom / oldZoom
|
||||||
|
val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio)
|
||||||
|
val desiredPageRoot = rootAnchor - newPageLocal
|
||||||
|
val delta = currentPageRootOffset - desiredPageRoot
|
||||||
|
return IntOffset(delta.x.roundToInt(), delta.y.roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfPaginationFirstRenderScale(
|
||||||
|
requestedScale: Float,
|
||||||
|
hasPageRender: Boolean,
|
||||||
|
isOpeningRender: Boolean = false
|
||||||
|
): Float {
|
||||||
|
if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) {
|
||||||
|
return requestedScale
|
||||||
|
}
|
||||||
|
return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopPdfZoomPreview(
|
||||||
|
val baseZoom: Float,
|
||||||
|
val zoom: Float,
|
||||||
|
val anchor: Offset?,
|
||||||
|
val displayMode: PdfDisplayMode,
|
||||||
|
val pageIndex: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopPdfCachedPageRender(
|
||||||
|
val render: DesktopPdfPageRender,
|
||||||
|
val scale: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun desktopPdfZoomPreviewPivotFraction(
|
||||||
|
viewportRootOffset: Offset,
|
||||||
|
pageRootOffset: Offset,
|
||||||
|
anchor: Offset,
|
||||||
|
pageCanvasSize: IntSize
|
||||||
|
): Offset? {
|
||||||
|
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null
|
||||||
|
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null
|
||||||
|
val pageAnchor = viewportRootOffset + anchor - pageRootOffset
|
||||||
|
if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null
|
||||||
|
return Offset(
|
||||||
|
x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f),
|
||||||
|
y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPdfDocumentZoomPreviewTranslation(
|
||||||
|
viewportRootOffset: Offset,
|
||||||
|
pageRootOffset: Offset,
|
||||||
|
anchor: Offset,
|
||||||
|
previewScale: Float
|
||||||
|
): Offset? {
|
||||||
|
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null
|
||||||
|
if (!previewScale.isFinite() || previewScale <= 0f) return null
|
||||||
|
val rootAnchor = viewportRootOffset + anchor
|
||||||
|
if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null
|
||||||
|
return Offset(
|
||||||
|
x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f),
|
||||||
|
y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Modifier.desktopPdfZoomPreviewLayer(
|
||||||
|
preview: DesktopPdfZoomPreview?,
|
||||||
|
currentZoom: Float,
|
||||||
|
viewportRootOffset: Offset,
|
||||||
|
pageRootOffset: Offset,
|
||||||
|
pageCanvasSize: IntSize
|
||||||
|
): Modifier {
|
||||||
|
val activePreview = preview ?: return this
|
||||||
|
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this
|
||||||
|
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||||
|
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||||
|
val previewScale = activePreview.zoom / currentZoom
|
||||||
|
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||||
|
val transformOrigin = activePreview.anchor?.let { anchor ->
|
||||||
|
desktopPdfZoomPreviewPivotFraction(
|
||||||
|
viewportRootOffset = viewportRootOffset,
|
||||||
|
pageRootOffset = pageRootOffset,
|
||||||
|
anchor = anchor,
|
||||||
|
pageCanvasSize = pageCanvasSize
|
||||||
|
)?.let { pivot ->
|
||||||
|
TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y)
|
||||||
|
} ?: TransformOrigin.Center
|
||||||
|
} ?: TransformOrigin.Center
|
||||||
|
return graphicsLayer {
|
||||||
|
scaleX = previewScale
|
||||||
|
scaleY = previewScale
|
||||||
|
this.transformOrigin = transformOrigin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
|
||||||
|
preview: DesktopPdfZoomPreview?,
|
||||||
|
currentZoom: Float,
|
||||||
|
viewportRootOffset: Offset,
|
||||||
|
pageRootOffset: Offset
|
||||||
|
): Modifier {
|
||||||
|
val activePreview = preview ?: return this
|
||||||
|
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||||
|
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||||
|
val previewScale = activePreview.zoom / currentZoom
|
||||||
|
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||||
|
val translation = activePreview.anchor?.let { anchor ->
|
||||||
|
desktopPdfDocumentZoomPreviewTranslation(
|
||||||
|
viewportRootOffset = viewportRootOffset,
|
||||||
|
pageRootOffset = pageRootOffset,
|
||||||
|
anchor = anchor,
|
||||||
|
previewScale = previewScale
|
||||||
|
)
|
||||||
|
} ?: Offset.Zero
|
||||||
|
return graphicsLayer {
|
||||||
|
scaleX = previewScale
|
||||||
|
scaleY = previewScale
|
||||||
|
translationX = translation.x
|
||||||
|
translationY = translation.y
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.desktopPdfZoomGestures(
|
||||||
|
currentZoom: Float,
|
||||||
|
zoomSpec: PdfZoomSpec,
|
||||||
|
onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit
|
||||||
|
): Modifier {
|
||||||
|
val latestZoom by rememberUpdatedState(currentZoom)
|
||||||
|
val latestOnZoomChanged by rememberUpdatedState(onZoomChanged)
|
||||||
|
return this.pointerInput(zoomSpec) {
|
||||||
|
var gestureZoom = latestZoom
|
||||||
|
var appliedGestureZoom = latestZoom
|
||||||
|
var lastZoomEventAt = 0L
|
||||||
|
var lastAppliedZoomAt = 0L
|
||||||
|
fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) {
|
||||||
|
if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) {
|
||||||
|
gestureZoom = latestZoom
|
||||||
|
appliedGestureZoom = latestZoom
|
||||||
|
lastAppliedZoomAt = 0L
|
||||||
|
}
|
||||||
|
val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor)
|
||||||
|
gestureZoom = newZoom
|
||||||
|
lastZoomEventAt = eventTime
|
||||||
|
val shouldApplyNow = lastAppliedZoomAt == 0L ||
|
||||||
|
eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis ||
|
||||||
|
newZoom == zoomSpec.min ||
|
||||||
|
newZoom == zoomSpec.max
|
||||||
|
if (shouldApplyNow && newZoom != appliedGestureZoom) {
|
||||||
|
latestOnZoomChanged(appliedGestureZoom, newZoom, anchor)
|
||||||
|
appliedGestureZoom = newZoom
|
||||||
|
lastAppliedZoomAt = eventTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L
|
||||||
|
if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) {
|
||||||
|
val scrollDelta = event.changes.fold(Offset.Zero) { total, change ->
|
||||||
|
total + change.scrollDelta
|
||||||
|
}
|
||||||
|
val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x
|
||||||
|
val factor = desktopPdfScrollZoomFactor(zoomDelta)
|
||||||
|
if (abs(factor - 1f) > 0.0001f) {
|
||||||
|
applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val pressedPointers = event.changes.count { it.pressed }
|
||||||
|
if (pressedPointers > 1) {
|
||||||
|
val zoomChange = event.calculateZoom()
|
||||||
|
if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) {
|
||||||
|
val centroid = event.calculateCentroid(useCurrent = false)
|
||||||
|
val anchor = if (centroid == Offset.Unspecified) {
|
||||||
|
event.changes.firstOrNull { it.pressed }?.position
|
||||||
|
} else {
|
||||||
|
centroid
|
||||||
|
}
|
||||||
|
applyZoomFactor(zoomChange, eventTime, anchor)
|
||||||
|
}
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
||||||
|
private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf"
|
||||||
|
private const val PdfLinkLogTag = "EpistemePdfLink"
|
||||||
|
private const val EpubLinkLogTag = "EpistemeEpubLink"
|
||||||
|
private const val EpubPaginationLogTag = "EpistemeEpubPagination"
|
||||||
|
private const val ReaderGapLogTag = "EpistemeReaderGap"
|
||||||
|
private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG"
|
||||||
|
|
||||||
|
internal fun logPdfSelection(message: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logPdfZoomPerf(message: String) {
|
||||||
|
logDesktopDiagnostic(PdfZoomPerfLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logPdfZoomPerf(message: () -> String) {
|
||||||
|
logDesktopDiagnostic(PdfZoomPerfLogTag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logPdfLink(message: String) {
|
||||||
|
logDesktopDiagnostic(PdfLinkLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logEpubLink(message: String) {
|
||||||
|
logDesktopDiagnostic(EpubLinkLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logEpubPagination(message: String) {
|
||||||
|
logDesktopDiagnostic(EpubPaginationLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logReaderGap(message: String) {
|
||||||
|
logDesktopDiagnostic(ReaderGapLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logEpubSelectionDebug(message: String) {
|
||||||
|
logDesktopDiagnostic(EpubSelectionDebugLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfLinkTarget.formatLogTarget(): String {
|
||||||
|
return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Float.formatLogFloat(): String {
|
||||||
|
return String.format("%.3f", this)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Offset?.formatLogOffset(): String {
|
||||||
|
if (this == null) return "none"
|
||||||
|
return "${x.formatLogFloat()},${y.formatLogFloat()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun IntSize.formatLogSize(): String {
|
||||||
|
return "${width}x${height}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String {
|
||||||
|
if (this == null) {
|
||||||
|
return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null"
|
||||||
|
}
|
||||||
|
return "${prefix}Index=$index ${prefix}Source=$source " +
|
||||||
|
"${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " +
|
||||||
|
"${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.BookItem
|
||||||
|
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||||
|
import com.aryan.reader.shared.ui.SharedAppTab
|
||||||
|
|
||||||
|
internal data class DesktopReaderOpening(
|
||||||
|
val requestId: Long,
|
||||||
|
val bookId: String,
|
||||||
|
val title: String,
|
||||||
|
val formatLabel: String,
|
||||||
|
val returnTab: SharedAppTab
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface DesktopReaderOpenResult {
|
||||||
|
val opening: DesktopReaderOpening
|
||||||
|
val book: BookItem
|
||||||
|
|
||||||
|
data class Pdf(
|
||||||
|
override val opening: DesktopReaderOpening,
|
||||||
|
override val book: BookItem,
|
||||||
|
val document: DesktopPdfDocument
|
||||||
|
) : DesktopReaderOpenResult
|
||||||
|
|
||||||
|
data class Text(
|
||||||
|
override val opening: DesktopReaderOpening,
|
||||||
|
override val book: BookItem,
|
||||||
|
val session: ReaderSessionState
|
||||||
|
) : DesktopReaderOpenResult
|
||||||
|
|
||||||
|
data class Failure(
|
||||||
|
override val opening: DesktopReaderOpening,
|
||||||
|
override val book: BookItem,
|
||||||
|
val message: String
|
||||||
|
) : DesktopReaderOpenResult
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,535 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
|
||||||
|
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||||
|
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||||
|
import com.aryan.reader.shared.ReaderAiFeature
|
||||||
|
import com.aryan.reader.shared.ReaderAiModelOption
|
||||||
|
import com.aryan.reader.shared.ReaderAiModelOptions
|
||||||
|
import com.aryan.reader.shared.ReaderAiResultState
|
||||||
|
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||||
|
import com.aryan.reader.shared.ReaderCloudTtsVoices
|
||||||
|
import com.aryan.reader.shared.ReaderExtrasState
|
||||||
|
import com.aryan.reader.shared.ReaderExternalLookupAction
|
||||||
|
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||||
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
|
import com.aryan.reader.shared.maskedReaderAiKey
|
||||||
|
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||||
|
import com.aryan.reader.shared.ui.SharedReaderPopupLayer
|
||||||
|
import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls
|
||||||
|
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||||
|
import com.aryan.reader.shared.ui.sharedReaderPopupWidth
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderBottomSheet(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
SharedReaderPopupLayer(onDismiss = onDismiss) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(40f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val sheetHorizontalPadding = 24.dp
|
||||||
|
val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(horizontal = sheetHorizontalPadding, vertical = 16.dp)
|
||||||
|
.width(sharedReaderPopupWidth(sheetAvailableWidth))
|
||||||
|
.heightIn(max = 560.dp),
|
||||||
|
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
shadowElevation = 16.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.width(42.dp)
|
||||||
|
.height(4.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp))
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderAiResultSheet(
|
||||||
|
result: ReaderAiResultState,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
DesktopReaderBottomSheet(
|
||||||
|
title = result.title ?: "AI",
|
||||||
|
onDismiss = onDismiss
|
||||||
|
) {
|
||||||
|
val errorMessage = result.errorMessage
|
||||||
|
when {
|
||||||
|
result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error)
|
||||||
|
else -> SharedMarkdownText(result.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopAiByokSettingsDialog(
|
||||||
|
settings: ReaderAiByokSettings,
|
||||||
|
secureStorageAvailable: Boolean,
|
||||||
|
onSettingsChange: (ReaderAiByokSettings) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sanitized = settings.sanitized()
|
||||||
|
var selectedProvider by remember { mutableStateOf("gemini") }
|
||||||
|
var pendingKey by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("AI keys and models") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 640.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (!secureStorageAvailable) {
|
||||||
|
Text(
|
||||||
|
"Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
DesktopSavedAiKeyRow(
|
||||||
|
label = "Gemini",
|
||||||
|
keyValue = sanitized.geminiKey,
|
||||||
|
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
|
||||||
|
)
|
||||||
|
DesktopSavedAiKeyRow(
|
||||||
|
label = "Groq",
|
||||||
|
keyValue = sanitized.groqKey,
|
||||||
|
onClear = { onSettingsChange(sanitized.copy(groqKey = "")) }
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedProvider == provider,
|
||||||
|
onClick = { selectedProvider = provider },
|
||||||
|
label = { Text(label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = pendingKey,
|
||||||
|
onValueChange = { pendingKey = it },
|
||||||
|
label = { Text("API key") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
enabled = pendingKey.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
val trimmed = pendingKey.trim()
|
||||||
|
val next = when (selectedProvider) {
|
||||||
|
"gemini" -> sanitized.copy(
|
||||||
|
geminiKey = trimmed,
|
||||||
|
ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID }
|
||||||
|
)
|
||||||
|
"groq" -> sanitized.copy(groqKey = trimmed)
|
||||||
|
else -> sanitized
|
||||||
|
}
|
||||||
|
onSettingsChange(next)
|
||||||
|
pendingKey = ""
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Save key")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Show AI in reader", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Matches the Android hide toggle for smart dictionary, summaries, and recaps.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = !sanitized.hideReaderAiFeatures,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Turn this off to choose separate models per reader AI feature.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = sanitized.useOneModel,
|
||||||
|
onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.useOneModel) {
|
||||||
|
DesktopAiModelSelector(
|
||||||
|
title = "All AI features",
|
||||||
|
description = "Smart dictionary, summaries, and recaps all use this model.",
|
||||||
|
selectedId = sanitized.modelForAll,
|
||||||
|
onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DesktopAiModelSelector(
|
||||||
|
title = "Smart dictionary",
|
||||||
|
description = "Used when defining selected words or phrases.",
|
||||||
|
selectedId = sanitized.defineModel,
|
||||||
|
onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) }
|
||||||
|
)
|
||||||
|
DesktopAiModelSelector(
|
||||||
|
title = "Summaries",
|
||||||
|
description = "Used for EPUB summaries and PDF page summaries.",
|
||||||
|
selectedId = sanitized.summarizeModel,
|
||||||
|
onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) }
|
||||||
|
)
|
||||||
|
DesktopAiModelSelector(
|
||||||
|
title = "Recaps",
|
||||||
|
description = "Used for story recap generation.",
|
||||||
|
selectedId = sanitized.recapModel,
|
||||||
|
onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopAiModelSelector(
|
||||||
|
title = "Cloud TTS",
|
||||||
|
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
||||||
|
selectedId = sanitized.ttsModel,
|
||||||
|
options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||||
|
onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) }
|
||||||
|
)
|
||||||
|
Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
ReaderCloudTtsVoices.chunked(3).forEach { rowVoices ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
rowVoices.forEach { voice ->
|
||||||
|
FilterChip(
|
||||||
|
selected = sanitized.ttsSpeakerId == voice.id,
|
||||||
|
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) },
|
||||||
|
label = {
|
||||||
|
Column {
|
||||||
|
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
voice.description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopSavedAiKeyRow(
|
||||||
|
label: String,
|
||||||
|
keyValue: String,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(label, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) {
|
||||||
|
Text("Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopAiModelSelector(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
selectedId: String,
|
||||||
|
options: List<ReaderAiModelOption> = ReaderAiModelOptions,
|
||||||
|
onSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedId.isBlank(),
|
||||||
|
onClick = { onSelected("") },
|
||||||
|
label = { Text("No model") }
|
||||||
|
)
|
||||||
|
options.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedId == option.id,
|
||||||
|
onClick = { onSelected(option.id) },
|
||||||
|
label = { Text(option.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopPdfExtrasPanel(
|
||||||
|
pageText: String,
|
||||||
|
recapText: String,
|
||||||
|
extrasState: ReaderExtrasState,
|
||||||
|
aiByokSettings: ReaderAiByokSettings,
|
||||||
|
externalLookupAvailable: Boolean,
|
||||||
|
cloudTtsFeatureAvailable: Boolean,
|
||||||
|
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
||||||
|
onAiAction: (ReaderAiFeature, String) -> Unit,
|
||||||
|
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
|
||||||
|
onCloudTtsPauseResume: () -> Unit,
|
||||||
|
onCloudTtsStop: () -> Unit,
|
||||||
|
onCloudTtsClearCache: () -> Unit,
|
||||||
|
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
|
||||||
|
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||||
|
ttsReplacementBookId: String,
|
||||||
|
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
||||||
|
) {
|
||||||
|
val settings = aiByokSettings.sanitized()
|
||||||
|
val autoScroll = extrasState.autoScroll.sanitized()
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
if (externalLookupAvailable) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
ReaderExternalLookupAction.entries.forEach { action ->
|
||||||
|
FilterChip(
|
||||||
|
selected = false,
|
||||||
|
enabled = pageText.isNotBlank(),
|
||||||
|
onClick = { onExternalLookup(action, pageText) },
|
||||||
|
label = { Text(action.title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Auto scroll", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = autoScroll.enabled,
|
||||||
|
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = autoScroll.speed,
|
||||||
|
onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) },
|
||||||
|
valueRange = 12f..160f
|
||||||
|
)
|
||||||
|
val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||||
|
if (cloudTtsFeatureAvailable) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
extrasState.cloudTts.isLoading -> "Preparing audio"
|
||||||
|
extrasState.cloudTts.isPaused -> "Paused"
|
||||||
|
extrasState.cloudTts.isPlaying -> "Reading"
|
||||||
|
settings.isCloudTtsAvailable -> "Cloud TTS ready"
|
||||||
|
else -> "Cloud TTS needs Gemini"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
extrasState.cloudTts.errorMessage?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
val statusMessage = extrasState.cloudTts.progress.currentPositionLabel
|
||||||
|
?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() }
|
||||||
|
statusMessage?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
enabled = settings.isCloudTtsAvailable || ttsBusy,
|
||||||
|
onClick = {
|
||||||
|
if (ttsBusy) {
|
||||||
|
onCloudTtsStop()
|
||||||
|
} else {
|
||||||
|
onCloudTtsStart(ReaderTtsReadScope.BOOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(if (ttsBusy) "Stop" else "Read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
TextButton(onClick = onCloudTtsPauseResume) {
|
||||||
|
Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
TextButton(
|
||||||
|
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||||
|
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
|
||||||
|
) {
|
||||||
|
Text("Page")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||||
|
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
|
||||||
|
) {
|
||||||
|
Text("From here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val cacheSummary = extrasState.cloudTts.cacheSummary
|
||||||
|
if (cacheSummary.hasCachedAudio) {
|
||||||
|
Text(
|
||||||
|
"Cache: ${cacheSummary.currentVoiceLabel}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (cacheSummary.hasCurrentVoiceCachedAudio) {
|
||||||
|
TextButton(onClick = onCloudTtsClearCache) {
|
||||||
|
Text("Clear voice cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedReaderTtsReplacementControls(
|
||||||
|
preferences = ttsReplacementPreferences,
|
||||||
|
bookId = ttsReplacementBookId,
|
||||||
|
onPreferencesChange = onTtsReplacementPreferencesChange
|
||||||
|
)
|
||||||
|
if (settings.areReaderAiFeaturesAvailable) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
|
TextButton(
|
||||||
|
enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading,
|
||||||
|
onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) }
|
||||||
|
) {
|
||||||
|
Text("Summarize page")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading,
|
||||||
|
onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) }
|
||||||
|
) {
|
||||||
|
Text("Recap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import com.aryan.reader.shared.ReaderTexture
|
||||||
|
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
internal object DesktopReaderTextures {
|
||||||
|
private val bytesCache = mutableMapOf<String, ByteArray?>()
|
||||||
|
private val dataUriCache = mutableMapOf<String, String?>()
|
||||||
|
private val imageCache = mutableMapOf<String, ImageBitmap?>()
|
||||||
|
private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
|
||||||
|
|
||||||
|
fun importedTextureIds(): List<String> {
|
||||||
|
return readerTextureDirectory()
|
||||||
|
.listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions }
|
||||||
|
?.sortedBy { it.name.lowercase(Locale.ROOT) }
|
||||||
|
?.map { ReaderTextureFilePrefix + it.absolutePath }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importTexture(source: File): String? {
|
||||||
|
if (!source.isFile) return null
|
||||||
|
val extension = source.extension.lowercase(Locale.ROOT)
|
||||||
|
.takeIf { it in importExtensions }
|
||||||
|
?: return null
|
||||||
|
val safeName = source.nameWithoutExtension
|
||||||
|
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
|
||||||
|
.trim('_')
|
||||||
|
.ifBlank { "texture" }
|
||||||
|
val directory = readerTextureDirectory().apply { mkdirs() }
|
||||||
|
val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension")
|
||||||
|
return runCatching {
|
||||||
|
source.copyTo(target, overwrite = false)
|
||||||
|
val textureId = ReaderTextureFilePrefix + target.absolutePath
|
||||||
|
bytesCache.remove(textureId)
|
||||||
|
dataUriCache.remove(textureId)
|
||||||
|
imageCache.remove(textureId)
|
||||||
|
textureId
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dataUriFor(textureId: String): String? {
|
||||||
|
return dataUriCache.getOrPut(textureId) {
|
||||||
|
val bytes = bytesFor(textureId) ?: return@getOrPut null
|
||||||
|
val extension = textureExtension(textureId)
|
||||||
|
"data:${imageMimeTypeForExtension(extension)};base64," +
|
||||||
|
Base64.getEncoder().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageBitmapFor(textureId: String?): ImageBitmap? {
|
||||||
|
val id = textureId ?: return null
|
||||||
|
return imageCache.getOrPut(id) {
|
||||||
|
val bytes = bytesFor(id) ?: return@getOrPut null
|
||||||
|
runCatching {
|
||||||
|
ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesFor(textureId: String): ByteArray? {
|
||||||
|
return bytesCache.getOrPut(textureId) {
|
||||||
|
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||||
|
File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes()
|
||||||
|
} else {
|
||||||
|
val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null
|
||||||
|
val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader
|
||||||
|
classLoader
|
||||||
|
?.getResourceAsStream(texture.assetPath)
|
||||||
|
?.use { it.readBytes() }
|
||||||
|
?: DesktopReaderTextures::class.java.classLoader
|
||||||
|
?.getResourceAsStream(texture.assetPath)
|
||||||
|
?.use { it.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun textureExtension(textureId: String): String {
|
||||||
|
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||||
|
return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension
|
||||||
|
}
|
||||||
|
return ReaderTexture.entries.firstOrNull { it.id == textureId }
|
||||||
|
?.assetPath
|
||||||
|
?.substringAfterLast('.', "png")
|
||||||
|
?: "png"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readerTextureDirectory(): File {
|
||||||
|
return File(desktopUserDataRoot(), "reader_textures")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageMimeTypeForExtension(extension: String): String {
|
||||||
|
return when (extension.lowercase(Locale.ROOT)) {
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
"gif" -> "image/gif"
|
||||||
|
"bmp" -> "image/bmp"
|
||||||
|
else -> "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.platform.Font as DesktopFont
|
||||||
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import com.aryan.reader.shared.reader.ReaderPage
|
||||||
|
import com.aryan.reader.shared.reader.ReaderSettings
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
|
||||||
|
customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||||
|
runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
return fontFamily.toComposeFontFamily()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toComposeFontFamily(): FontFamily {
|
||||||
|
return when (this) {
|
||||||
|
"Serif" -> FontFamily.Serif
|
||||||
|
"Sans" -> FontFamily.SansSerif
|
||||||
|
"Mono" -> FontFamily.Monospace
|
||||||
|
else -> FontFamily.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
|
||||||
|
if (size != other.size) return false
|
||||||
|
return indices.all { index ->
|
||||||
|
val left = this[index]
|
||||||
|
val right = other[index]
|
||||||
|
left.pageIndex == right.pageIndex &&
|
||||||
|
left.chapterIndex == right.chapterIndex &&
|
||||||
|
left.startOffset == right.startOffset &&
|
||||||
|
left.endOffset == right.endOffset &&
|
||||||
|
left.text.length == right.text.length &&
|
||||||
|
left.semanticBlocks.size == right.semanticBlocks.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? {
|
||||||
|
return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam
|
||||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) {
|
enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) {
|
||||||
DEFAULT("default", "", "Default"),
|
DEFAULT("default", "", "Default"),
|
||||||
LEFT("left", "left", "Left"),
|
LEFT("left", "left", "Left"),
|
||||||
|
RIGHT("right", "right", "Right"),
|
||||||
JUSTIFY("justify", "justify", "Justify")
|
JUSTIFY("justify", "justify", "Justify")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +177,7 @@ fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
ReaderTextAlign.DEFAULT,
|
ReaderTextAlign.DEFAULT,
|
||||||
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||||
|
ReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
|
||||||
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,7 @@ object ReaderHtmlDocumentBuilder {
|
||||||
val appearance = settings.toDocumentAppearanceCss(textureDataUri)
|
val appearance = settings.toDocumentAppearanceCss(textureDataUri)
|
||||||
val align = when (settings.textAlign) {
|
val align = when (settings.textAlign) {
|
||||||
SharedReaderTextAlign.START -> "left"
|
SharedReaderTextAlign.START -> "left"
|
||||||
|
SharedReaderTextAlign.RIGHT -> "right"
|
||||||
SharedReaderTextAlign.JUSTIFY -> "justify"
|
SharedReaderTextAlign.JUSTIFY -> "justify"
|
||||||
SharedReaderTextAlign.CENTER -> "center"
|
SharedReaderTextAlign.CENTER -> "center"
|
||||||
}
|
}
|
||||||
|
|
@ -418,13 +419,14 @@ object ReaderHtmlDocumentBuilder {
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: min(300px, calc(100vw - 16px));
|
max-width: min(280px, calc(100vw - 16px));
|
||||||
padding: 0 0 6px;
|
padding: 0 0 6px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: color-mix(in srgb, var(--reader-bg) 92%, var(--reader-fg));
|
background: color-mix(in srgb, var(--reader-bg) 92%, var(--reader-fg));
|
||||||
border: 1px solid color-mix(in srgb, var(--reader-fg) 18%, transparent);
|
border: 1px solid color-mix(in srgb, var(--reader-fg) 18%, transparent);
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||||||
overflow: hidden;
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
#reader-selection-menu button {
|
#reader-selection-menu button {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
@ -440,16 +442,16 @@ object ReaderHtmlDocumentBuilder {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--reader-fg) 12%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--reader-fg) 12%, transparent);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
#reader-selection-menu .reader-selection-color {
|
#reader-selection-menu .reader-selection-color {
|
||||||
width: 28px;
|
width: 24px;
|
||||||
height: 28px;
|
height: 24px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -458,34 +460,34 @@ object ReaderHtmlDocumentBuilder {
|
||||||
}
|
}
|
||||||
#reader-selection-menu .reader-selection-actions {
|
#reader-selection-menu .reader-selection-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 78px);
|
grid-template-columns: repeat(3, 70px);
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
padding: 6px 8px 2px;
|
padding: 5px 6px 2px;
|
||||||
}
|
}
|
||||||
#reader-selection-menu .reader-selection-action {
|
#reader-selection-menu .reader-selection-action {
|
||||||
min-height: 58px;
|
min-height: 52px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 4px;
|
||||||
padding: 7px 4px;
|
padding: 6px 4px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#reader-selection-menu .reader-selection-icon {
|
#reader-selection-menu .reader-selection-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 24px;
|
width: 22px;
|
||||||
height: 24px;
|
height: 22px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--reader-fg) 9%, transparent);
|
background: color-mix(in srgb, var(--reader-fg) 9%, transparent);
|
||||||
color: color-mix(in srgb, var(--reader-fg) 86%, transparent);
|
color: color-mix(in srgb, var(--reader-fg) 86%, transparent);
|
||||||
}
|
}
|
||||||
#reader-selection-menu .reader-selection-icon svg {
|
#reader-selection-menu .reader-selection-icon svg {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
display: block;
|
display: block;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
@ -1185,18 +1187,90 @@ object ReaderHtmlDocumentBuilder {
|
||||||
function positionMenu(left, top, anchorRect) {
|
function positionMenu(left, top, anchorRect) {
|
||||||
menu.style.visibility = 'hidden';
|
menu.style.visibility = 'hidden';
|
||||||
menu.style.display = 'flex';
|
menu.style.display = 'flex';
|
||||||
|
var margin = 8;
|
||||||
|
var gap = 14;
|
||||||
|
var viewportWidth = Math.max(0, window.innerWidth || 0);
|
||||||
|
var viewportHeight = Math.max(0, window.innerHeight || 0);
|
||||||
|
menu.style.maxHeight = Math.max(0, viewportHeight - margin * 2) + 'px';
|
||||||
var menuWidth = menu.offsetWidth || 300;
|
var menuWidth = menu.offsetWidth || 300;
|
||||||
var menuHeight = menu.offsetHeight || 230;
|
var menuHeight = menu.offsetHeight || 230;
|
||||||
var margin = 8;
|
|
||||||
var nextLeft = left;
|
function clampMenuStart(preferred, size, viewportSize) {
|
||||||
var nextTop = top;
|
if (viewportSize <= 0 || size <= 0) return 0;
|
||||||
if (anchorRect) {
|
if (viewportSize <= size) return 0;
|
||||||
nextLeft = anchorRect.left + (anchorRect.width / 2) - (menuWidth / 2);
|
var maxStart = viewportSize - size;
|
||||||
nextTop = anchorRect.top - menuHeight - 14;
|
var minStart = Math.min(margin, maxStart);
|
||||||
if (nextTop < margin) nextTop = anchorRect.bottom + 14;
|
var maxClampedStart = Math.max(minStart, viewportSize - size - margin);
|
||||||
|
return Math.max(minStart, Math.min(maxClampedStart, preferred));
|
||||||
|
}
|
||||||
|
function selectionMenuCandidate(x, y) {
|
||||||
|
return {
|
||||||
|
left: clampMenuStart(x, menuWidth, viewportWidth),
|
||||||
|
top: clampMenuStart(y, menuHeight, viewportHeight)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function overlapAreaWithSelection(candidate, rect) {
|
||||||
|
var overlapWidth = Math.min(candidate.left + menuWidth, rect.right) - Math.max(candidate.left, rect.left);
|
||||||
|
var overlapHeight = Math.min(candidate.top + menuHeight, rect.bottom) - Math.max(candidate.top, rect.top);
|
||||||
|
return Math.max(0, overlapWidth) * Math.max(0, overlapHeight);
|
||||||
|
}
|
||||||
|
function distanceFromSelection(candidate, rect) {
|
||||||
|
var dx = Math.max(rect.left - (candidate.left + menuWidth), candidate.left - rect.right, 0);
|
||||||
|
var dy = Math.max(rect.top - (candidate.top + menuHeight), candidate.top - rect.bottom, 0);
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
var rect = anchorRect ? {
|
||||||
|
left: Math.max(0, Math.min(viewportWidth, Math.min(anchorRect.left, anchorRect.right))),
|
||||||
|
top: Math.max(0, Math.min(viewportHeight, Math.min(anchorRect.top, anchorRect.bottom))),
|
||||||
|
right: Math.max(0, Math.min(viewportWidth, Math.max(anchorRect.left, anchorRect.right))),
|
||||||
|
bottom: Math.max(0, Math.min(viewportHeight, Math.max(anchorRect.top, anchorRect.bottom)))
|
||||||
|
} : {
|
||||||
|
left: Math.max(0, Math.min(viewportWidth, left)),
|
||||||
|
top: Math.max(0, Math.min(viewportHeight, top)),
|
||||||
|
right: Math.max(0, Math.min(viewportWidth, left)),
|
||||||
|
bottom: Math.max(0, Math.min(viewportHeight, top))
|
||||||
|
};
|
||||||
|
var centerX = (rect.left + rect.right) / 2;
|
||||||
|
var centerY = (rect.top + rect.bottom) / 2;
|
||||||
|
var above = selectionMenuCandidate(centerX - menuWidth / 2, rect.top - gap - menuHeight);
|
||||||
|
var below = selectionMenuCandidate(centerX - menuWidth / 2, rect.bottom + gap);
|
||||||
|
var right = selectionMenuCandidate(rect.right + gap, centerY - menuHeight / 2);
|
||||||
|
var leftSide = selectionMenuCandidate(rect.left - gap - menuWidth, centerY - menuHeight / 2);
|
||||||
|
var nextLeft = above.left;
|
||||||
|
var nextTop = above.top;
|
||||||
|
if (above.top + menuHeight <= rect.top - gap && above.top >= margin) {
|
||||||
|
nextLeft = above.left;
|
||||||
|
nextTop = above.top;
|
||||||
|
} else if (below.top >= rect.bottom + gap && below.top + menuHeight <= viewportHeight - margin) {
|
||||||
|
nextLeft = below.left;
|
||||||
|
nextTop = below.top;
|
||||||
|
} else {
|
||||||
|
var leftSpace = rect.left - gap - margin;
|
||||||
|
var rightSpace = viewportWidth - rect.right - gap - margin;
|
||||||
|
var firstSide = rightSpace >= leftSpace ? right : leftSide;
|
||||||
|
var secondSide = rightSpace >= leftSpace ? leftSide : right;
|
||||||
|
var firstFits = firstSide === right
|
||||||
|
? firstSide.left >= rect.right + gap
|
||||||
|
: firstSide.left + menuWidth <= rect.left - gap;
|
||||||
|
var secondFits = secondSide === right
|
||||||
|
? secondSide.left >= rect.right + gap
|
||||||
|
: secondSide.left + menuWidth <= rect.left - gap;
|
||||||
|
if (firstFits && firstSide.top >= margin && firstSide.top + menuHeight <= viewportHeight - margin) {
|
||||||
|
nextLeft = firstSide.left;
|
||||||
|
nextTop = firstSide.top;
|
||||||
|
} else if (secondFits && secondSide.top >= margin && secondSide.top + menuHeight <= viewportHeight - margin) {
|
||||||
|
nextLeft = secondSide.left;
|
||||||
|
nextTop = secondSide.top;
|
||||||
|
} else {
|
||||||
|
var fallback = [above, below, firstSide, secondSide].sort(function (a, b) {
|
||||||
|
var overlapDelta = overlapAreaWithSelection(a, rect) - overlapAreaWithSelection(b, rect);
|
||||||
|
if (overlapDelta !== 0) return overlapDelta;
|
||||||
|
return distanceFromSelection(a, rect) - distanceFromSelection(b, rect);
|
||||||
|
})[0];
|
||||||
|
nextLeft = fallback.left;
|
||||||
|
nextTop = fallback.top;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nextLeft = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, nextLeft));
|
|
||||||
nextTop = Math.max(margin, Math.min(window.innerHeight - menuHeight - margin, nextTop));
|
|
||||||
menu.style.left = nextLeft + 'px';
|
menu.style.left = nextLeft + 'px';
|
||||||
menu.style.top = nextTop + 'px';
|
menu.style.top = nextTop + 'px';
|
||||||
menu.style.visibility = 'visible';
|
menu.style.visibility = 'visible';
|
||||||
|
|
@ -2458,7 +2532,11 @@ object ReaderHtmlDocumentBuilder {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('pointercancel', function () {
|
document.addEventListener('pointercancel', function () {
|
||||||
if (!activeSelectionHandle) selectionPointerDown = false;
|
if (!activeSelectionHandle) {
|
||||||
|
selectionPointerDown = false;
|
||||||
|
scheduleMenuFromSelection();
|
||||||
|
scheduleVisiblePageReport();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('mouseup', function (event) {
|
document.addEventListener('mouseup', function (event) {
|
||||||
if (menu.contains(event.target)) return;
|
if (menu.contains(event.target)) return;
|
||||||
|
|
@ -2467,6 +2545,19 @@ object ReaderHtmlDocumentBuilder {
|
||||||
scheduleMenuFromSelection();
|
scheduleMenuFromSelection();
|
||||||
scheduleVisiblePageReport();
|
scheduleVisiblePageReport();
|
||||||
});
|
});
|
||||||
|
document.addEventListener('touchend', function (event) {
|
||||||
|
if (menu.contains(event.target)) return;
|
||||||
|
if (activeSelectionHandle) return;
|
||||||
|
selectionPointerDown = false;
|
||||||
|
scheduleMenuFromSelection();
|
||||||
|
scheduleVisiblePageReport();
|
||||||
|
}, { passive: true });
|
||||||
|
document.addEventListener('touchcancel', function () {
|
||||||
|
if (activeSelectionHandle) return;
|
||||||
|
selectionPointerDown = false;
|
||||||
|
scheduleMenuFromSelection();
|
||||||
|
scheduleVisiblePageReport();
|
||||||
|
}, { passive: true });
|
||||||
document.addEventListener('keyup', function () {
|
document.addEventListener('keyup', function () {
|
||||||
scheduleMenuFromSelection();
|
scheduleMenuFromSelection();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ enum class ReaderPageSpreadMode {
|
||||||
|
|
||||||
enum class SharedReaderTextAlign {
|
enum class SharedReaderTextAlign {
|
||||||
START,
|
START,
|
||||||
|
RIGHT,
|
||||||
JUSTIFY,
|
JUSTIFY,
|
||||||
CENTER
|
CENTER
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -350,9 +350,10 @@ fun SharedNativePaginatedReader(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!selectionGestureActive && !selectionHandleDragging) {
|
if (!selectionGestureActive && !selectionHandleDragging) {
|
||||||
|
val highlightPalette = renderPlan.highlightPalette.sanitized().colors
|
||||||
SharedNativeSelectionMenu(
|
SharedNativeSelectionMenu(
|
||||||
selection = selection,
|
selection = selection,
|
||||||
highlightPalette = renderPlan.highlightPalette.sanitized().colors,
|
highlightPalette = highlightPalette,
|
||||||
enabledSelectionActions = enabledSelectionActions,
|
enabledSelectionActions = enabledSelectionActions,
|
||||||
background = renderPlan.background,
|
background = renderPlan.background,
|
||||||
foreground = renderPlan.foreground,
|
foreground = renderPlan.foreground,
|
||||||
|
|
@ -375,7 +376,9 @@ fun SharedNativePaginatedReader(
|
||||||
sharedNativeSelectionMenuOffset(
|
sharedNativeSelectionMenuOffset(
|
||||||
selection = selection,
|
selection = selection,
|
||||||
readerCoordinates = readerCoordinates,
|
readerCoordinates = readerCoordinates,
|
||||||
density = density
|
density = density,
|
||||||
|
highlightPaletteSize = highlightPalette.size,
|
||||||
|
actionCount = enabledSelectionActions.size + 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -620,21 +623,21 @@ private fun SharedNativeSelectionMenu(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(IntrinsicSize.Max)
|
.width(IntrinsicSize.Max)
|
||||||
.widthIn(max = 300.dp)
|
.widthIn(max = 280.dp)
|
||||||
.padding(bottom = 6.dp)
|
.padding(bottom = 6.dp)
|
||||||
) {
|
) {
|
||||||
if (highlightPalette.isNotEmpty()) {
|
if (highlightPalette.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
highlightPalette.forEach { color ->
|
highlightPalette.forEach { color ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(24.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color.color)
|
.background(color.color)
|
||||||
.border(
|
.border(
|
||||||
|
|
@ -650,8 +653,8 @@ private fun SharedNativeSelectionMenu(
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 8.dp, top = 6.dp, end = 8.dp, bottom = 2.dp),
|
.padding(start = 6.dp, top = 5.dp, end = 6.dp, bottom = 2.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||||
) {
|
) {
|
||||||
actions.chunked(3).forEach { rowActions ->
|
actions.chunked(3).forEach { rowActions ->
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -688,17 +691,17 @@ private fun SharedNativeSelectionIconButton(
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(78.dp)
|
.width(70.dp)
|
||||||
.height(58.dp)
|
.height(52.dp)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.clickable { action.onClick() }
|
.clickable { action.onClick() }
|
||||||
.padding(horizontal = 4.dp, vertical = 7.dp),
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically)
|
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(24.dp)
|
.size(22.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(iconBackground),
|
.background(iconBackground),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
|
@ -707,7 +710,7 @@ private fun SharedNativeSelectionIconButton(
|
||||||
imageVector = action.icon,
|
imageVector = action.icon,
|
||||||
contentDescription = action.label,
|
contentDescription = action.label,
|
||||||
tint = iconColor,
|
tint = iconColor,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1878,27 +1881,53 @@ private data class SharedNativeSelectionEndpoint(
|
||||||
private fun sharedNativeSelectionMenuOffset(
|
private fun sharedNativeSelectionMenuOffset(
|
||||||
selection: SharedNativeReaderTextSelection,
|
selection: SharedNativeReaderTextSelection,
|
||||||
readerCoordinates: LayoutCoordinates?,
|
readerCoordinates: LayoutCoordinates?,
|
||||||
density: Density
|
density: Density,
|
||||||
|
highlightPaletteSize: Int,
|
||||||
|
actionCount: Int
|
||||||
): IntOffset {
|
): IntOffset {
|
||||||
val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16)
|
val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16)
|
||||||
if (selection.rect == Rect.Zero) return IntOffset(16, 16)
|
if (selection.rect == Rect.Zero) return IntOffset(16, 16)
|
||||||
val centerX = (selection.rect.left + selection.rect.right) / 2f
|
val leftTopLocal = coordinates.windowToLocal(Offset(selection.rect.left, selection.rect.top))
|
||||||
val topLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.top))
|
val rightBottomLocal = coordinates.windowToLocal(Offset(selection.rect.right, selection.rect.bottom))
|
||||||
val bottomLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.bottom))
|
|
||||||
val paddingPx = with(density) { 16.dp.toPx() }
|
val paddingPx = with(density) { 16.dp.toPx() }
|
||||||
val estimatedWidthPx = with(density) { 300.dp.toPx() }
|
val estimatedWidthPx = with(density) { 280.dp.toPx() }
|
||||||
val estimatedHeightPx = with(density) { 154.dp.toPx() }
|
val estimatedHeightPx = sharedNativeSelectionMenuEstimatedHeightPx(
|
||||||
val maxX = (coordinates.size.width - estimatedWidthPx - paddingPx).coerceAtLeast(paddingPx)
|
density = density,
|
||||||
val x = (topLocal.x - estimatedWidthPx / 2f).coerceIn(paddingPx, maxX)
|
highlightPaletteSize = highlightPaletteSize,
|
||||||
val yAbove = topLocal.y - estimatedHeightPx - paddingPx
|
actionCount = actionCount
|
||||||
val y = if (yAbove >= paddingPx) {
|
|
||||||
yAbove
|
|
||||||
} else {
|
|
||||||
(bottomLocal.y + paddingPx).coerceAtMost(
|
|
||||||
(coordinates.size.height - estimatedHeightPx - paddingPx).coerceAtLeast(paddingPx)
|
|
||||||
)
|
)
|
||||||
|
val selectionRect = SharedSelectionMenuRect(
|
||||||
|
left = leftTopLocal.x,
|
||||||
|
top = leftTopLocal.y,
|
||||||
|
right = rightBottomLocal.x,
|
||||||
|
bottom = rightBottomLocal.y
|
||||||
|
)
|
||||||
|
val placement = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(coordinates.size.width, coordinates.size.height),
|
||||||
|
popup = SharedSelectionMenuSize(
|
||||||
|
width = estimatedWidthPx.roundToInt(),
|
||||||
|
height = estimatedHeightPx.roundToInt()
|
||||||
|
),
|
||||||
|
selection = selectionRect,
|
||||||
|
marginPx = paddingPx,
|
||||||
|
gapPx = paddingPx
|
||||||
|
)
|
||||||
|
return IntOffset(placement.x, placement.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sharedNativeSelectionMenuEstimatedHeightPx(
|
||||||
|
density: Density,
|
||||||
|
highlightPaletteSize: Int,
|
||||||
|
actionCount: Int
|
||||||
|
): Float {
|
||||||
|
val actionRows = ((actionCount.coerceAtLeast(1) + 2) / 3).coerceAtLeast(1)
|
||||||
|
return with(density) {
|
||||||
|
val paletteHeight = if (highlightPaletteSize > 0) 41.dp.toPx() else 0f
|
||||||
|
val actionsHeight = 7.dp.toPx() +
|
||||||
|
(actionRows * 52).dp.toPx() +
|
||||||
|
((actionRows - 1).coerceAtLeast(0) * 3).dp.toPx()
|
||||||
|
paletteHeight + actionsHeight
|
||||||
}
|
}
|
||||||
return IntOffset(x.roundToInt(), y.roundToInt())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sharedNativeSelectionHandleOffset(
|
private fun sharedNativeSelectionHandleOffset(
|
||||||
|
|
@ -2395,6 +2424,7 @@ private fun ReaderSettings.renderedDefaultBlockSpacingDp(): Dp {
|
||||||
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
|
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
SharedReaderTextAlign.START -> TextAlign.Start
|
SharedReaderTextAlign.START -> TextAlign.Start
|
||||||
|
SharedReaderTextAlign.RIGHT -> TextAlign.Right
|
||||||
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
||||||
SharedReaderTextAlign.CENTER -> TextAlign.Center
|
SharedReaderTextAlign.CENTER -> TextAlign.Center
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1495,6 +1495,13 @@ fun SharedReaderFormatControls(
|
||||||
},
|
},
|
||||||
label = { Text("Left") }
|
label = { Text("Left") }
|
||||||
)
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = settings.textAlign == SharedReaderTextAlign.RIGHT,
|
||||||
|
onClick = {
|
||||||
|
onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.RIGHT)))
|
||||||
|
},
|
||||||
|
label = { Text("Right") }
|
||||||
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY,
|
selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package com.aryan.reader.shared.ui
|
||||||
|
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class SharedSelectionMenuViewport(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SharedSelectionMenuSize(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SharedSelectionMenuRect(
|
||||||
|
val left: Float,
|
||||||
|
val top: Float,
|
||||||
|
val right: Float,
|
||||||
|
val bottom: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SharedSelectionMenuPlacement {
|
||||||
|
ABOVE,
|
||||||
|
BELOW,
|
||||||
|
LEFT,
|
||||||
|
RIGHT,
|
||||||
|
FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SharedSelectionMenuPlacementResult(
|
||||||
|
val x: Int,
|
||||||
|
val y: Int,
|
||||||
|
val placement: SharedSelectionMenuPlacement
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sharedSelectionMenuPlacement(
|
||||||
|
viewport: SharedSelectionMenuViewport,
|
||||||
|
popup: SharedSelectionMenuSize,
|
||||||
|
selection: SharedSelectionMenuRect,
|
||||||
|
marginPx: Float,
|
||||||
|
gapPx: Float
|
||||||
|
): SharedSelectionMenuPlacementResult {
|
||||||
|
val viewportWidth = viewport.width.coerceAtLeast(0).toFloat()
|
||||||
|
val viewportHeight = viewport.height.coerceAtLeast(0).toFloat()
|
||||||
|
val popupWidth = popup.width.coerceAtLeast(0).toFloat()
|
||||||
|
val popupHeight = popup.height.coerceAtLeast(0).toFloat()
|
||||||
|
val margin = marginPx.coerceAtLeast(0f)
|
||||||
|
val gap = gapPx.coerceAtLeast(0f)
|
||||||
|
val keepClear = selection.normalized().clampedToViewport(viewportWidth, viewportHeight)
|
||||||
|
|
||||||
|
fun centeredX(): Float = keepClear.centerX - popupWidth / 2f
|
||||||
|
fun centeredY(): Float = keepClear.centerY - popupHeight / 2f
|
||||||
|
fun clamped(x: Float, y: Float): SharedSelectionMenuCandidate {
|
||||||
|
return SharedSelectionMenuCandidate(
|
||||||
|
x = clampStart(x, popupWidth, viewportWidth, margin),
|
||||||
|
y = clampStart(y, popupHeight, viewportHeight, margin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val above = clamped(centeredX(), keepClear.top - gap - popupHeight)
|
||||||
|
if (above.y + popupHeight <= keepClear.top - gap && above.y >= margin) {
|
||||||
|
return above.toResult(SharedSelectionMenuPlacement.ABOVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val below = clamped(centeredX(), keepClear.bottom + gap)
|
||||||
|
if (below.y >= keepClear.bottom + gap && below.y + popupHeight <= viewportHeight - margin) {
|
||||||
|
return below.toResult(SharedSelectionMenuPlacement.BELOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
val leftSpace = keepClear.left - gap - margin
|
||||||
|
val rightSpace = viewportWidth - keepClear.right - gap - margin
|
||||||
|
val sideCandidates = if (rightSpace >= leftSpace) {
|
||||||
|
listOf(
|
||||||
|
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY()),
|
||||||
|
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY()),
|
||||||
|
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sideCandidates.forEach { (placement, candidate) ->
|
||||||
|
val fitsHorizontally = when (placement) {
|
||||||
|
SharedSelectionMenuPlacement.LEFT -> candidate.x + popupWidth <= keepClear.left - gap
|
||||||
|
SharedSelectionMenuPlacement.RIGHT -> candidate.x >= keepClear.right + gap
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (fitsHorizontally && candidate.y >= margin && candidate.y + popupHeight <= viewportHeight - margin) {
|
||||||
|
return candidate.toResult(placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
above,
|
||||||
|
below,
|
||||||
|
sideCandidates[0].second,
|
||||||
|
sideCandidates[1].second
|
||||||
|
).minWith(
|
||||||
|
compareBy<SharedSelectionMenuCandidate> { candidate ->
|
||||||
|
candidate.overlapAreaWith(keepClear, popupWidth, popupHeight)
|
||||||
|
}.thenBy { candidate ->
|
||||||
|
candidate.distanceFrom(keepClear, popupWidth, popupHeight)
|
||||||
|
}
|
||||||
|
).toResult(SharedSelectionMenuPlacement.FALLBACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SharedSelectionMenuCandidate(
|
||||||
|
val x: Float,
|
||||||
|
val y: Float
|
||||||
|
) {
|
||||||
|
fun toResult(placement: SharedSelectionMenuPlacement): SharedSelectionMenuPlacementResult {
|
||||||
|
return SharedSelectionMenuPlacementResult(
|
||||||
|
x = x.roundToInt(),
|
||||||
|
y = y.roundToInt(),
|
||||||
|
placement = placement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun overlapAreaWith(
|
||||||
|
rect: SharedSelectionMenuRect,
|
||||||
|
width: Float,
|
||||||
|
height: Float
|
||||||
|
): Float {
|
||||||
|
val overlapWidth = min(x + width, rect.right) - max(x, rect.left)
|
||||||
|
val overlapHeight = min(y + height, rect.bottom) - max(y, rect.top)
|
||||||
|
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun distanceFrom(
|
||||||
|
rect: SharedSelectionMenuRect,
|
||||||
|
width: Float,
|
||||||
|
height: Float
|
||||||
|
): Float {
|
||||||
|
val dx = maxOf(rect.left - (x + width), x - rect.right, 0f)
|
||||||
|
val dy = maxOf(rect.top - (y + height), y - rect.bottom, 0f)
|
||||||
|
return dx * dx + dy * dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSelectionMenuRect.normalized(): SharedSelectionMenuRect {
|
||||||
|
return SharedSelectionMenuRect(
|
||||||
|
left = min(left, right),
|
||||||
|
top = min(top, bottom),
|
||||||
|
right = max(left, right),
|
||||||
|
bottom = max(top, bottom)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SharedSelectionMenuRect.centerX: Float
|
||||||
|
get() = (left + right) / 2f
|
||||||
|
|
||||||
|
private val SharedSelectionMenuRect.centerY: Float
|
||||||
|
get() = (top + bottom) / 2f
|
||||||
|
|
||||||
|
private fun SharedSelectionMenuRect.clampedToViewport(
|
||||||
|
viewportWidth: Float,
|
||||||
|
viewportHeight: Float
|
||||||
|
): SharedSelectionMenuRect {
|
||||||
|
return SharedSelectionMenuRect(
|
||||||
|
left = left.coerceIn(0f, viewportWidth),
|
||||||
|
top = top.coerceIn(0f, viewportHeight),
|
||||||
|
right = right.coerceIn(0f, viewportWidth),
|
||||||
|
bottom = bottom.coerceIn(0f, viewportHeight)
|
||||||
|
).normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clampStart(
|
||||||
|
preferred: Float,
|
||||||
|
popupSize: Float,
|
||||||
|
viewportSize: Float,
|
||||||
|
margin: Float
|
||||||
|
): Float {
|
||||||
|
if (viewportSize <= 0f || popupSize <= 0f) return 0f
|
||||||
|
if (viewportSize <= popupSize) return 0f
|
||||||
|
val maxStart = viewportSize - popupSize
|
||||||
|
val min = margin.coerceIn(0f, maxStart)
|
||||||
|
val max = (viewportSize - popupSize - margin).coerceAtLeast(min)
|
||||||
|
return preferred.coerceIn(min, max)
|
||||||
|
}
|
||||||
|
|
@ -287,7 +287,7 @@ class ReaderActionReducerTest {
|
||||||
verticalMargin = 2.0f,
|
verticalMargin = 2.0f,
|
||||||
font = ReaderFont.ROBOTO_MONO,
|
font = ReaderFont.ROBOTO_MONO,
|
||||||
customPath = null,
|
customPath = null,
|
||||||
textAlign = ReaderTextAlign.JUSTIFY
|
textAlign = ReaderTextAlign.RIGHT
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
engine
|
engine
|
||||||
|
|
@ -301,7 +301,7 @@ class ReaderActionReducerTest {
|
||||||
assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f)
|
assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f)
|
||||||
assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f)
|
assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f)
|
||||||
assertEquals("Mono", updated.reader.settings.fontFamily)
|
assertEquals("Mono", updated.reader.settings.fontFamily)
|
||||||
assertEquals(SharedReaderTextAlign.JUSTIFY, updated.reader.settings.textAlign)
|
assertEquals(SharedReaderTextAlign.RIGHT, updated.reader.settings.textAlign)
|
||||||
assertTrue(updated.reader.settings.darkMode)
|
assertTrue(updated.reader.settings.darkMode)
|
||||||
assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode)
|
assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode)
|
||||||
assertEquals(812, updated.reader.settings.pageWidth)
|
assertEquals(812, updated.reader.settings.pageWidth)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@ import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ReaderHtmlDocumentBuilderTest {
|
class ReaderHtmlDocumentBuilderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `page document writes right alignment reader css variable`() {
|
||||||
|
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||||
|
book = repeatedWordBook("alpha beta"),
|
||||||
|
page = ReaderPage(0, 0, "One", "alpha beta", 0, 10),
|
||||||
|
settings = ReaderSettings(textAlign = SharedReaderTextAlign.RIGHT)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(html.contains("--reader-align: right;"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `page document renders only the highlighted occurrence from locator offsets`() {
|
fun `page document renders only the highlighted occurrence from locator offsets`() {
|
||||||
val text = "alpha beta alpha beta"
|
val text = "alpha beta alpha beta"
|
||||||
|
|
@ -321,11 +332,14 @@ class ReaderHtmlDocumentBuilderTest {
|
||||||
|
|
||||||
assertTrue(html.contains("function scheduleMenuFromSelection()"))
|
assertTrue(html.contains("function scheduleMenuFromSelection()"))
|
||||||
assertTrue(html.contains("selectionAnchorRect(selection)"))
|
assertTrue(html.contains("selectionAnchorRect(selection)"))
|
||||||
|
assertTrue(html.contains("selectionMenuCandidate"))
|
||||||
|
assertTrue(html.contains("overlapAreaWithSelection"))
|
||||||
assertTrue(html.contains("if (selectionPointerDown || activeSelectionHandle) return;"))
|
assertTrue(html.contains("if (selectionPointerDown || activeSelectionHandle) return;"))
|
||||||
assertTrue(html.contains("rangeBoundaryRect(range.startContainer"))
|
assertTrue(html.contains("rangeBoundaryRect(range.startContainer"))
|
||||||
assertTrue(html.contains("document.addEventListener('selectionchange'"))
|
assertTrue(html.contains("document.addEventListener('selectionchange'"))
|
||||||
assertTrue(html.contains("document.addEventListener('pointerdown'"))
|
assertTrue(html.contains("document.addEventListener('pointerdown'"))
|
||||||
assertTrue(html.contains("document.addEventListener('mouseup'"))
|
assertTrue(html.contains("document.addEventListener('mouseup'"))
|
||||||
|
assertTrue(html.contains("document.addEventListener('touchend'"))
|
||||||
assertTrue(html.contains("document.addEventListener('contextmenu'"))
|
assertTrue(html.contains("document.addEventListener('contextmenu'"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.aryan.reader.shared.ui
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class SharedSelectionMenuPlacementTest {
|
||||||
|
@Test
|
||||||
|
fun `places menu above selection when there is room`() {
|
||||||
|
val result = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
|
||||||
|
popup = SharedSelectionMenuSize(width = 240, height = 120),
|
||||||
|
selection = SharedSelectionMenuRect(left = 300f, top = 300f, right = 360f, bottom = 330f),
|
||||||
|
marginPx = 16f,
|
||||||
|
gapPx = 12f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(SharedSelectionMenuPlacement.ABOVE, result.placement)
|
||||||
|
assertEquals(210, result.x)
|
||||||
|
assertEquals(168, result.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `places menu below selection when above is blocked`() {
|
||||||
|
val result = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
|
||||||
|
popup = SharedSelectionMenuSize(width = 240, height = 120),
|
||||||
|
selection = SharedSelectionMenuRect(left = 300f, top = 40f, right = 360f, bottom = 70f),
|
||||||
|
marginPx = 16f,
|
||||||
|
gapPx = 12f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(SharedSelectionMenuPlacement.BELOW, result.placement)
|
||||||
|
assertEquals(210, result.x)
|
||||||
|
assertEquals(82, result.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `places menu on wider side in short landscape viewport`() {
|
||||||
|
val result = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
|
||||||
|
popup = SharedSelectionMenuSize(width = 240, height = 180),
|
||||||
|
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
|
||||||
|
marginPx = 16f,
|
||||||
|
gapPx = 12f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(SharedSelectionMenuPlacement.RIGHT, result.placement)
|
||||||
|
assertEquals(392, result.x)
|
||||||
|
assertEquals(65, result.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps menu off selected text when a valid side placement exists`() {
|
||||||
|
val result = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
|
||||||
|
popup = SharedSelectionMenuSize(width = 240, height = 180),
|
||||||
|
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
|
||||||
|
marginPx = 16f,
|
||||||
|
gapPx = 12f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0f, result.rect(width = 240, height = 180).overlapAreaWith(
|
||||||
|
SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back predictably when selection consumes the viewport`() {
|
||||||
|
val result = sharedSelectionMenuPlacement(
|
||||||
|
viewport = SharedSelectionMenuViewport(width = 320, height = 220),
|
||||||
|
popup = SharedSelectionMenuSize(width = 260, height = 180),
|
||||||
|
selection = SharedSelectionMenuRect(left = 10f, top = 20f, right = 310f, bottom = 200f),
|
||||||
|
marginPx = 16f,
|
||||||
|
gapPx = 12f
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(SharedSelectionMenuPlacement.FALLBACK, result.placement)
|
||||||
|
assertEquals(30, result.x)
|
||||||
|
assertEquals(16, result.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSelectionMenuPlacementResult.rect(
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
): SharedSelectionMenuRect {
|
||||||
|
return SharedSelectionMenuRect(
|
||||||
|
left = x.toFloat(),
|
||||||
|
top = y.toFloat(),
|
||||||
|
right = x + width.toFloat(),
|
||||||
|
bottom = y + height.toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedSelectionMenuRect.overlapAreaWith(other: SharedSelectionMenuRect): Float {
|
||||||
|
val overlapWidth = minOf(right, other.right) - maxOf(left, other.left)
|
||||||
|
val overlapHeight = minOf(bottom, other.bottom) - maxOf(top, other.top)
|
||||||
|
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
@ -719,6 +719,7 @@ class SharedMeasuredEpubPaginator(
|
||||||
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
|
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
SharedReaderTextAlign.START -> TextAlign.Start
|
SharedReaderTextAlign.START -> TextAlign.Start
|
||||||
|
SharedReaderTextAlign.RIGHT -> TextAlign.Right
|
||||||
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
|
||||||
SharedReaderTextAlign.CENTER -> TextAlign.Center
|
SharedReaderTextAlign.CENTER -> TextAlign.Center
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue