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:
Aryan 2026-05-16 16:19:31 +05:30 committed by GitHub
parent 759d4b73a0
commit 056485a140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 9184 additions and 5947 deletions

View file

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

View file

@ -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; }";
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(" - ")
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() },

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {}

View 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
)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

@ -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())
}

View file

@ -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]}"
}
}

View file

@ -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("\"", "\\\"")
}

View file

@ -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()
}

View file

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

View file

@ -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")
}
}
}
}

View file

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

View file

@ -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,}")

View file

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

View file

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

View file

@ -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() }
}
}
}
}
}

View file

@ -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()}"
}

View file

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

View file

@ -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")
}
}
}
}
}

View file

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

View file

@ -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()
}

View file

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

View file

@ -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();
}); });

View file

@ -45,6 +45,7 @@ enum class ReaderPageSpreadMode {
enum class SharedReaderTextAlign { enum class SharedReaderTextAlign {
START, START,
RIGHT,
JUSTIFY, JUSTIFY,
CENTER CENTER
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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'"))
} }

View file

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

View file

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