Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
parent
a13d6599d1
commit
625a4d5d2e
102 changed files with 6012 additions and 687 deletions
|
|
@ -171,6 +171,7 @@ android {
|
|||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all {
|
||||
it.maxHeapSize = "4g"
|
||||
it.jvmArgs("-Xss2m")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,8 @@ class LibraryScreenContentTest {
|
|||
onItemClick = {},
|
||||
onItemLongClick = { item -> selectedItems.value = setOf(item) },
|
||||
onInfoClick = onInfoClick,
|
||||
onSaveClick = null,
|
||||
onShareClick = null,
|
||||
onDeleteClick = onDeleteClick,
|
||||
onSelectAllClick = onSelectAllClick,
|
||||
onShelfClick = onShelfClick,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,22 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TemporaryExternalFileActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Reader"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="standard" />
|
||||
|
||||
<activity
|
||||
android:name=".ExternalFileOpenRouterActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:noHistory="true"
|
||||
android:excludeFromRecents="true">
|
||||
<!-- PDF -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
|
|||
|
|
@ -128,8 +128,15 @@
|
|||
background-color: rgba(255, 236, 179, 0.8);
|
||||
/* Semi-transparent Gold */
|
||||
color: black !important;
|
||||
display: inline !important;
|
||||
text-align: initial !important;
|
||||
text-align-last: auto !important;
|
||||
letter-spacing: normal !important;
|
||||
word-spacing: normal !important;
|
||||
padding: 0.1em 0;
|
||||
border-radius: 3px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
html.dark-theme span.tts-highlight {
|
||||
|
|
@ -1496,6 +1503,16 @@
|
|||
};
|
||||
|
||||
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
|
||||
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
|
||||
|
||||
function getTtsHighlightBlock(node) {
|
||||
if (!node) {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
|
||||
}
|
||||
|
||||
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
|
||||
console.log(`$ {
|
||||
|
|
@ -1556,9 +1573,10 @@
|
|||
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
|
||||
|
||||
const baseNode = location.node;
|
||||
const highlightRoot = getTtsHighlightBlock(baseNode);
|
||||
let remainingOffset = startOffset;
|
||||
|
||||
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||
treeWalker.currentNode = baseNode;
|
||||
|
||||
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
|
||||
|
|
@ -1626,7 +1644,7 @@
|
|||
} else {
|
||||
remainingTextLength -= availableLength;
|
||||
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
|
||||
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
|
||||
nextNodeWalker.currentNode = endNode;
|
||||
endNode = nextNodeWalker.nextNode();
|
||||
endOffset = 0; // Start from the beginning of the next node
|
||||
|
|
@ -1677,11 +1695,14 @@
|
|||
TTS_HIGHLIGHT_LOG_TAG
|
||||
}
|
||||
|
||||
: surroundContents failed, using fallback. Error: $ {
|
||||
: surroundContents failed, using same-block fallback. Error: $ {
|
||||
e.message
|
||||
}
|
||||
|
||||
`);
|
||||
if (!highlightRoot.contains(range.commonAncestorContainer)) {
|
||||
return "JS: Highlight range escaped current block.";
|
||||
}
|
||||
const contents = range.extractContents();
|
||||
highlightSpan.appendChild(contents);
|
||||
range.insertNode(highlightSpan);
|
||||
|
|
|
|||
|
|
@ -220,11 +220,17 @@ fun AppNavigation(
|
|||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||
composable(AppDestinations.MAIN_ROUTE) {
|
||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
if (uiState.isTemporaryExternalOpen) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PDF Viewer Screen Composable
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ data class ReaderScreenState(
|
|||
val selectedEpubUri: Uri? = null,
|
||||
val selectedFileType: FileType? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isTemporaryExternalOpen: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||
|
|
|
|||
31
app/src/main/java/com/aryan/reader/ClipboardUtils.kt
Normal file
31
app/src/main/java/com/aryan/reader/ClipboardUtils.kt
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import timber.log.Timber
|
||||
|
||||
internal fun copyPlainTextToClipboard(
|
||||
context: Context,
|
||||
label: String,
|
||||
text: String
|
||||
): Boolean {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(label, text)
|
||||
return setPrimaryClipSafely {
|
||||
clipboard.setPrimaryClip(clip)
|
||||
}
|
||||
}
|
||||
|
||||
internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
|
||||
return try {
|
||||
setPrimaryClip()
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
Timber.w(e, "Clipboard write rejected by system policy")
|
||||
false
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e, "Clipboard write failed")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
|
|
@ -862,6 +863,13 @@ fun AiDefinitionPopup(
|
|||
val ttsController = rememberTtsController()
|
||||
val ttsState by ttsController.ttsState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxPopupHeight = readerModalMaxHeightDp(
|
||||
screenHeightDp = configuration.screenHeightDp,
|
||||
fraction = 0.65f,
|
||||
verticalMarginDp = 48,
|
||||
preferredMinHeightDp = 180
|
||||
).dp
|
||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -882,7 +890,7 @@ fun AiDefinitionPopup(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||
.heightIn(min = 150.dp, max = 400.dp),
|
||||
.heightIn(max = maxPopupHeight),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
|
|
@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(16.dp)
|
||||
.heightIn(max = maxDialogHeight)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(16.dp)
|
||||
.heightIn(max = maxDialogHeight)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
57
app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
Normal file
57
app/src/main/java/com/aryan/reader/ExternalFileOpenRouter.kt
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
||||
const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "com.aryan.reader.extra.TEMPORARY_EXTERNAL_OPEN"
|
||||
|
||||
object ExternalFileOpenRouteDecider {
|
||||
const val BEHAVIOR_TEMPORARY = "TEMPORARY"
|
||||
|
||||
fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
|
||||
return externalFileBehavior == BEHAVIOR_TEMPORARY
|
||||
}
|
||||
|
||||
fun targetActivityClass(externalFileBehavior: String?): Class<out Activity> {
|
||||
return if (shouldOpenTemporary(externalFileBehavior)) {
|
||||
TemporaryExternalFileActivity::class.java
|
||||
} else {
|
||||
MainActivity::class.java
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalFileOpenRouterActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
routeExternalOpen(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
routeExternalOpen(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun routeExternalOpen(sourceIntent: Intent?) {
|
||||
if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
|
||||
|
||||
val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
val behavior = prefs.getString("external_file_behavior", "ASK")
|
||||
val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
|
||||
val targetIntent = Intent(sourceIntent).apply {
|
||||
setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (temporary) {
|
||||
putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
|
||||
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
}
|
||||
}
|
||||
startActivity(targetIntent)
|
||||
}
|
||||
}
|
||||
|
||||
class TemporaryExternalFileActivity : MainActivity()
|
||||
|
|
@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.CustomFontFamilyItem
|
||||
import com.aryan.reader.shared.CustomFontVariantItem
|
||||
import com.aryan.reader.shared.fontFaceLabel
|
||||
import com.aryan.reader.shared.fontFaceSummary
|
||||
import com.aryan.reader.shared.groupByFamily
|
||||
import com.aryan.reader.shared.hasVariableWeightFace
|
||||
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
||||
|
|
@ -97,6 +104,7 @@ fun FontsScreen(
|
|||
val selectedFonts = remember(fonts, selectedFontIds) {
|
||||
fonts.filter { it.id in selectedFontIds }
|
||||
}
|
||||
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
|
||||
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
|
||||
|
||||
LaunchedEffect(fonts) {
|
||||
|
|
@ -172,6 +180,7 @@ fun FontsScreen(
|
|||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
||||
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SharedFontSettingsTabs(
|
||||
selectedSection = selectedSection,
|
||||
|
|
@ -202,16 +211,20 @@ fun FontsScreen(
|
|||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(fonts, key = { it.id }) { font ->
|
||||
FontListItem(
|
||||
font = font,
|
||||
isSelected = font.id in selectedFontIds,
|
||||
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
|
||||
FontFamilyListItem(
|
||||
family = family,
|
||||
selectedFontIds = selectedFontIds,
|
||||
isSelectionMode = isFontSelectionMode,
|
||||
onSelectionToggle = {
|
||||
selectedFontIds = selectedFontIds.toggle(font.id)
|
||||
fontEntityForId = { id -> fontEntitiesById[id] },
|
||||
onVariantSelectionToggle = { id ->
|
||||
selectedFontIds = selectedFontIds.toggle(id)
|
||||
},
|
||||
onDelete = {
|
||||
fontsPendingDelete = listOf(font)
|
||||
onFamilySelectionToggle = {
|
||||
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
|
||||
},
|
||||
onDeleteVariant = { id ->
|
||||
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -531,6 +544,188 @@ fun FontListItem(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FontFamilyListItem(
|
||||
family: CustomFontFamilyItem,
|
||||
selectedFontIds: Set<String>,
|
||||
isSelectionMode: Boolean,
|
||||
fontEntityForId: (String) -> CustomFontEntity?,
|
||||
onVariantSelectionToggle: (String) -> Unit,
|
||||
onFamilySelectionToggle: () -> Unit,
|
||||
onDeleteVariant: (String) -> Unit
|
||||
) {
|
||||
val baseFont = remember(family) {
|
||||
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
|
||||
}
|
||||
val customTypeface = remember(baseFont.path) {
|
||||
try {
|
||||
FontFamily(Font(File(baseFont.path)))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
|
||||
val isSelected = familyFontIds.any { it in selectedFontIds }
|
||||
val allSelected = familyFontIds.all { it in selectedFontIds }
|
||||
val faceSummary = remember(family) {
|
||||
buildString {
|
||||
append(family.fontFaceSummary())
|
||||
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||
append(" - ${family.variants.size} file")
|
||||
if (family.variants.size != 1) append("s")
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (isSelectionMode) {
|
||||
onFamilySelectionToggle()
|
||||
}
|
||||
},
|
||||
onLongClick = onFamilySelectionToggle
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
Checkbox(
|
||||
checked = allSelected,
|
||||
onCheckedChange = { onFamilySelectionToggle() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = family.familyName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = faceSummary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
if (customTypeface != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.font_preview_text),
|
||||
fontFamily = customTypeface,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.font_preview_error),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
family.variants.forEachIndexed { index, variant ->
|
||||
FontVariantRow(
|
||||
variant = variant,
|
||||
entity = fontEntityForId(variant.font.id),
|
||||
isSelected = variant.font.id in selectedFontIds,
|
||||
isSelectionMode = isSelectionMode,
|
||||
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
|
||||
onDelete = { onDeleteVariant(variant.font.id) }
|
||||
)
|
||||
if (index != family.variants.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FontVariantRow(
|
||||
variant: CustomFontVariantItem,
|
||||
entity: CustomFontEntity?,
|
||||
isSelected: Boolean,
|
||||
isSelectionMode: Boolean,
|
||||
onSelectionToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isSelectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = variant.fontFaceLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = entity?.fileName ?: variant.font.fileName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = variant.font.fileExtension.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
if (!isSelectionMode) {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||
return filterNot { it.isDeleted }
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
|
|
@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog(
|
|||
private fun Set<String>.toggle(id: String): Set<String> {
|
||||
return if (id in this) this - id else this + id
|
||||
}
|
||||
|
||||
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
|
||||
val idSet = ids.toSet()
|
||||
return if (containsAll(idSet)) this - idSet else this + idSet
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
|
|
@ -193,6 +194,7 @@ fun HomeScreen(
|
|||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var showBehaviorDialog by remember { mutableStateOf(false) }
|
||||
var showStrictFilterDialog by remember { mutableStateOf(false) }
|
||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -221,6 +223,35 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isRequestingDrivePermission) {
|
||||
if (uiState.isRequestingDrivePermission) {
|
||||
val intent = viewModel.getDriveSignInIntent(context)
|
||||
|
|
@ -390,6 +421,12 @@ fun HomeScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedContextItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedContextItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||
|
|
@ -502,28 +539,17 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
|
||||
if (showClearReflowCacheDialog) {
|
||||
DangerousFolderActionDialog(
|
||||
|
|
@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.options_external_file_behavior)) },
|
||||
text = {
|
||||
Column {
|
||||
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
|
||||
options.forEach { (value, labelRes) ->
|
||||
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
|
||||
val options = listOf(
|
||||
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
|
||||
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
|
||||
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
|
||||
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
|
||||
)
|
||||
options.forEach { (value, labelRes, descriptionRes) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
|
@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
|
|||
) {
|
||||
RadioButton(selected = currentBehavior == value, onClick = null)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(stringResource(labelRes))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(labelRes))
|
||||
Text(
|
||||
text = stringResource(descriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
|
@ -271,6 +272,36 @@ fun LibraryScreen(
|
|||
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = isContextualModeActive) {
|
||||
viewModel.clearContextualAction()
|
||||
|
|
@ -317,6 +348,12 @@ fun LibraryScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
|
||||
onShelfClick = viewModel::onShelfClick,
|
||||
|
|
@ -397,28 +434,17 @@ fun LibraryScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(item.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(item.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(item.bookId)
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -436,10 +462,42 @@ fun ShelfScreen(
|
|||
val sortOrder = uiState.sortOrder
|
||||
val showRenameDialogFor = uiState.showRenameShelfDialogFor
|
||||
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||
|
||||
val saveOriginalLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri ->
|
||||
val item = pendingSaveOriginalItem
|
||||
pendingSaveOriginalItem = null
|
||||
if (uri != null && item?.uriString != null) {
|
||||
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalItem(item: RecentFileItem) {
|
||||
if (!item.canExportOriginalFile()) return
|
||||
pendingSaveOriginalItem = item
|
||||
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
|
||||
}
|
||||
|
||||
fun shareOriginalItem(item: RecentFileItem) {
|
||||
val uriString = item.uriString ?: return
|
||||
if (!item.canExportOriginalFile()) return
|
||||
scope.launch {
|
||||
viewModel.shareOriginalFile(
|
||||
activityContext = context,
|
||||
sourceUri = uriString.toUri(),
|
||||
fileType = item.type,
|
||||
filename = item.suggestedOriginalFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
when {
|
||||
|
|
@ -491,6 +549,12 @@ fun ShelfScreen(
|
|||
showInfoDialog = true
|
||||
}
|
||||
},
|
||||
onSaveClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { saveOriginalItem(item) } },
|
||||
onShareClick = selectedItems.singleOrNull()
|
||||
?.takeIf { it.canExportOriginalFile() }
|
||||
?.let { item -> { shareOriginalItem(item) } },
|
||||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||
|
|
@ -531,19 +595,14 @@ fun ShelfScreen(
|
|||
)
|
||||
}
|
||||
|
||||
itemForInfoDialog?.let { item ->
|
||||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HydratedFileInfoDialog(
|
||||
item = itemForInfoDialog,
|
||||
isVisible = showInfoDialog,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
|
||||
)
|
||||
CustomTopBanner(bannerMessage = uiState.bannerMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -578,6 +637,8 @@ fun LibraryScreenContent(
|
|||
onItemClick: (RecentFileItem) -> Unit,
|
||||
onItemLongClick: (RecentFileItem) -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onSaveClick: (() -> Unit)?,
|
||||
onShareClick: (() -> Unit)?,
|
||||
onDeleteClick: () -> Unit,
|
||||
onSelectAllClick: () -> Unit,
|
||||
onShelfClick: (Shelf) -> Unit,
|
||||
|
|
@ -640,6 +701,8 @@ fun LibraryScreenContent(
|
|||
onTagClick = onTagClick,
|
||||
onPinClick = onPinClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onSaveClick = onSaveClick,
|
||||
onShareClick = onShareClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
onSelectAllClick = onSelectAllClick
|
||||
)
|
||||
|
|
@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen(
|
|||
onClearSelection: () -> Unit,
|
||||
onTagClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onSaveClick: (() -> Unit)?,
|
||||
onShareClick: (() -> Unit)?,
|
||||
onDeleteClick: () -> Unit,
|
||||
onRenameShelf: () -> Unit,
|
||||
onDeleteShelf: () -> Unit,
|
||||
|
|
@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen(
|
|||
onNavIconClick = onClearSelection,
|
||||
onTagClick = onTagClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onSaveClick = onSaveClick,
|
||||
onShareClick = onShareClick,
|
||||
onDeleteClick = onDeleteClick
|
||||
)
|
||||
} else if (isSearchActive) {
|
||||
|
|
|
|||
|
|
@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
|
|||
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : AppCompatActivity() {
|
||||
open class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
|
||||
private val isTemporaryExternalOpen: Boolean
|
||||
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
|
||||
|
||||
private val updateLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartIntentSenderForResult()
|
||||
|
|
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.temporaryExternalOpenFinished.collect {
|
||||
if (isTemporaryExternalOpen) {
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
|
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
|
|||
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
||||
val uri = intent.data!!
|
||||
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
|
||||
viewModel.onFileSelected(
|
||||
uri,
|
||||
isFromRecent = false,
|
||||
isExternalIntent = true,
|
||||
isTemporaryExternalIntent = isTemporaryExternalOpen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
|
||||
@Suppress("unused")
|
||||
val navigationEvent = _navigationEvent.receiveAsFlow()
|
||||
private val _temporaryExternalOpenFinished = Channel<Unit>(Channel.BUFFERED)
|
||||
val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
|
||||
private var bannerDismissJob: Job? = null
|
||||
private var bannerDismissGeneration = 0L
|
||||
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
|
||||
private var externalOpenedBookId: String? = null
|
||||
private var temporaryExternalSessionBookId: String? = null
|
||||
private var cloudContentRetryJob: Job? = null
|
||||
private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
|
|
@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
|
||||
}
|
||||
|
||||
suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
|
||||
return recentFilesRepository.getFileByBookId(bookId)
|
||||
}
|
||||
|
||||
fun createAndAssignTag(name: String, bookIds: Set<String>) {
|
||||
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
|
||||
if (sanitizedBookIds.isEmpty()) return
|
||||
|
|
@ -2089,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
internal fun trackExternalOpenForClose(
|
||||
bookId: String,
|
||||
importedCopyUriString: String?,
|
||||
isTemporaryExternalIntent: Boolean
|
||||
) {
|
||||
if (isTemporaryExternalIntent) {
|
||||
temporaryExternalSessionBookId = bookId
|
||||
if (importedCopyUriString != null) {
|
||||
externalOpenedBookId = bookId
|
||||
markPendingExternalFileRemoval(bookId, importedCopyUriString)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
externalOpenedBookId = bookId
|
||||
if (
|
||||
importedCopyUriString != null &&
|
||||
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
|
||||
) {
|
||||
markPendingExternalFileRemoval(bookId, importedCopyUriString)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
|
||||
viewModelScope.launch {
|
||||
_internalState.update {
|
||||
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
|
||||
}
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
copyUriBytes(sourceUri, destUri)
|
||||
}
|
||||
showBanner(appContext.getString(R.string.banner_original_file_saved))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to save original file")
|
||||
showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
|
||||
} finally {
|
||||
_internalState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePinForContextualItems(isHome: Boolean) {
|
||||
if (_internalState.value.contextualActionItems.isEmpty()) return
|
||||
|
||||
|
|
@ -2206,6 +2255,68 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun shareOriginalFile(
|
||||
activityContext: Context,
|
||||
sourceUri: Uri,
|
||||
fileType: FileType,
|
||||
filename: String
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shareDir = File(appContext.cacheDir, "shared_files")
|
||||
if (shareDir.exists()) {
|
||||
shareDir.listFiles()?.forEach { file ->
|
||||
try {
|
||||
file.delete()
|
||||
} catch (_: Exception) {
|
||||
Timber.w("Failed to delete temp share file: ${file.name}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shareDir.mkdirs()
|
||||
}
|
||||
|
||||
val destFile = File(shareDir, filename)
|
||||
FileOutputStream(destFile).use { output ->
|
||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
input.copyTo(output)
|
||||
} ?: error("Could not open source file.")
|
||||
}
|
||||
|
||||
val authority = "${appContext.packageName}.provider"
|
||||
val contentUri = androidx.core.content.FileProvider.getUriForFile(
|
||||
appContext, authority, destFile
|
||||
)
|
||||
val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||
putExtra(Intent.EXTRA_TITLE, filename)
|
||||
putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
|
||||
clipData = ClipData.newRawUri(filename, contentUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
|
||||
if (activityContext !is android.app.Activity) {
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Share original file failed")
|
||||
showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
|
||||
val contentResolver = appContext.contentResolver
|
||||
contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
contentResolver.openOutputStream(destUri)?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: error("Could not open destination file.")
|
||||
} ?: error("Could not open source file.")
|
||||
}
|
||||
|
||||
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
|
||||
if (!uiState.value.isSyncEnabled) return
|
||||
cloudMetadataUploadJobs.remove(bookId)?.cancel()
|
||||
|
|
@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val closingBookId = _internalState.value.selectedBookId
|
||||
val uriString = _internalState.value.selectedPdfUri?.toString()
|
||||
?: _internalState.value.selectedEpubUri?.toString()
|
||||
val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
|
||||
logCloudSyncTrace {
|
||||
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
|
||||
}
|
||||
|
|
@ -2787,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
selectedEpubBook = null,
|
||||
selectedFileType = null,
|
||||
isLoading = false,
|
||||
isTemporaryExternalOpen = false,
|
||||
errorMessage = null,
|
||||
initialLocator = null,
|
||||
initialPageInBook = null,
|
||||
|
|
@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
isOpeningFromTtsNotification = false
|
||||
)
|
||||
}
|
||||
clearPersistedReaderSession()
|
||||
if (!isTemporaryExternalSession) {
|
||||
clearPersistedReaderSession()
|
||||
}
|
||||
|
||||
var removesExternalFileOnClose = false
|
||||
if (closingBookId != null && closingBookId == externalOpenedBookId) {
|
||||
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
|
||||
if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
|
||||
val behavior = if (isTemporaryExternalSession) {
|
||||
EXTERNAL_FILE_BEHAVIOR_TEMPORARY
|
||||
} else {
|
||||
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
|
||||
}
|
||||
if (behavior == "ASK") {
|
||||
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
|
||||
} else if (behavior == "DELETE") {
|
||||
removesExternalFileOnClose = true
|
||||
deletePendingExternalFileRemoval(closingBookId, uriString)
|
||||
} else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
|
||||
removesExternalFileOnClose = true
|
||||
val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
|
||||
if (shouldDeleteImportedCopy) {
|
||||
viewModelScope.launch {
|
||||
deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
|
||||
_temporaryExternalOpenFinished.send(Unit)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
_temporaryExternalOpenFinished.send(Unit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearPendingExternalFileRemovals(setOf(closingBookId))
|
||||
}
|
||||
externalOpenedBookId = null
|
||||
temporaryExternalSessionBookId = null
|
||||
}
|
||||
|
||||
if (uriString != null && !removesExternalFileOnClose) {
|
||||
|
|
@ -4802,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
|
||||
fun onFileSelected(
|
||||
uri: Uri,
|
||||
isFromRecent: Boolean = false,
|
||||
isExternalIntent: Boolean = false,
|
||||
isTemporaryExternalIntent: Boolean = false
|
||||
) {
|
||||
if (isFromRecent) {
|
||||
Timber.i("Opening recent file: $uri")
|
||||
viewModelScope.launch {
|
||||
|
|
@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
} else {
|
||||
Timber.i("Importing new file: $uri")
|
||||
importExternalFile(uri, isExternalIntent)
|
||||
importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4865,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
|
||||
private fun importExternalFile(
|
||||
externalUri: Uri,
|
||||
isExternalIntent: Boolean = false,
|
||||
isTemporaryExternalIntent: Boolean = false
|
||||
) {
|
||||
if (isTemporaryExternalIntent) {
|
||||
openTemporaryExternalFile(externalUri)
|
||||
return
|
||||
}
|
||||
|
||||
_internalState.update {
|
||||
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
contextualActionItems = emptySet()
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
|
|
@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
if (importResult != null) {
|
||||
val (internalUri, bookId, type) = importResult
|
||||
if (isExternalIntent) {
|
||||
externalOpenedBookId = bookId
|
||||
if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") {
|
||||
markPendingExternalFileRemoval(bookId, internalUri.toString())
|
||||
}
|
||||
trackExternalOpenForClose(
|
||||
bookId = bookId,
|
||||
importedCopyUriString = internalUri.toString(),
|
||||
isTemporaryExternalIntent = isTemporaryExternalIntent
|
||||
)
|
||||
}
|
||||
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
|
||||
openBook(
|
||||
|
|
@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val existingItem = recentFilesRepository.getFileByBookId(hash)
|
||||
if (existingItem != null) {
|
||||
Timber.i("Re-selected an existing book. Opening it.")
|
||||
if (isTemporaryExternalIntent) {
|
||||
trackExternalOpenForClose(
|
||||
bookId = existingItem.bookId,
|
||||
importedCopyUriString = null,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
}
|
||||
onRecentFileClicked(existingItem)
|
||||
_internalState.update { it.copy(isLoading = false) }
|
||||
return@launch
|
||||
|
|
@ -4928,6 +5087,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun openTemporaryExternalFile(externalUri: Uri) {
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
isTemporaryExternalOpen = true,
|
||||
errorMessage = null,
|
||||
contextualActionItems = emptySet()
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val type = getFileTypeFromUri(externalUri, appContext)
|
||||
if (type == null) {
|
||||
_internalState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isTemporaryExternalOpen = false,
|
||||
errorMessage = appContext.getString(R.string.error_unsupported_file_type)
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
|
||||
val bookId = "temporary-${UUID.randomUUID()}"
|
||||
trackExternalOpenForClose(
|
||||
bookId = bookId,
|
||||
importedCopyUriString = null,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
openBook(
|
||||
uri = externalUri,
|
||||
bookId = bookId,
|
||||
type = type,
|
||||
originalDisplayName = displayName,
|
||||
persistToLibrary = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveHighlights(bookId: String, highlightsJson: String) {
|
||||
viewModelScope.launch {
|
||||
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
|
||||
|
|
@ -5154,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupBookDataLocally(bookId: String) {
|
||||
protected open suspend fun cleanupBookDataLocally(bookId: String) {
|
||||
pdfTextRepository.clearBookText(bookId)
|
||||
clearImportedFileCache(bookId)
|
||||
bookCacheDao.deleteEntireBookCache(bookId)
|
||||
|
|
@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
isInitialPageExplicit: Boolean = false,
|
||||
initialLocatorOverride: Locator? = null,
|
||||
initialCfiOverride: String? = null,
|
||||
preserveTtsOnOpen: Boolean = false
|
||||
preserveTtsOnOpen: Boolean = false,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
val openBookStartTime = System.currentTimeMillis()
|
||||
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
||||
|
|
@ -5293,16 +5492,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
)
|
||||
}
|
||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
persistReaderSession(bookId, type)
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
customDisplayName = originalDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
persistReaderSession(bookId, type)
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
customDisplayName = originalDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
|
||||
if (!suppressNavigation) {
|
||||
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
|
||||
|
|
@ -5343,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
)
|
||||
}
|
||||
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
persistReaderSession(bookId, type)
|
||||
if (persistToLibrary) {
|
||||
persistReaderSession(bookId, type)
|
||||
}
|
||||
|
||||
if (!suppressNavigation) {
|
||||
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
|
||||
|
|
@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
when (type) {
|
||||
FileType.EPUB -> {
|
||||
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
||||
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||
}
|
||||
|
||||
FileType.MOBI -> {
|
||||
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
||||
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||
}
|
||||
|
||||
FileType.FB2 -> {
|
||||
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
||||
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||
}
|
||||
FileType.ODT, FileType.FODT -> {
|
||||
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult)
|
||||
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
|
||||
}
|
||||
else -> {
|
||||
loadSingleFile(
|
||||
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult
|
||||
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5390,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
||||
private fun loadFb2(
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
customDisplayName: String? = null,
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
val loadStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
|
||||
viewModelScope.launch {
|
||||
|
|
@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||
|
||||
addFileToRecent(
|
||||
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
addFileToRecent(
|
||||
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
|
||||
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -5426,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
||||
private fun loadOdt(
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
isFlat: Boolean,
|
||||
customDisplayName: String? = null,
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
val loadStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
|
||||
viewModelScope.launch {
|
||||
|
|
@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||
|
||||
addFileToRecent(
|
||||
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
addFileToRecent(
|
||||
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
|
||||
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
bookId: String,
|
||||
type: FileType,
|
||||
customDisplayName: String? = null,
|
||||
bundleResult: CalibreBundleResult? = null
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
val loadStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
|
||||
|
|
@ -5506,16 +5727,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||
Timber.i("Import successful ($type). Title: ${epubBook.title}")
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
epubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
addFileToRecent(
|
||||
uri,
|
||||
type,
|
||||
bookId,
|
||||
epubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
|
||||
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
||||
Timber.tag("FileOpenPerf")
|
||||
|
|
@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
return resolveFileTypeFromMetadata(fileName, mimeType)
|
||||
}
|
||||
|
||||
private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
||||
private fun loadMobi(
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
customDisplayName: String? = null,
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
if (!_internalState.value.isLoading) {
|
||||
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
|
@ -5576,16 +5805,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
if (mobiAsEpubBook != null) {
|
||||
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
|
||||
addFileToRecent(
|
||||
uri,
|
||||
FileType.MOBI,
|
||||
bookId,
|
||||
mobiAsEpubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
addFileToRecent(
|
||||
uri,
|
||||
FileType.MOBI,
|
||||
bookId,
|
||||
mobiAsEpubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
_internalState.update {
|
||||
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
|
||||
}
|
||||
|
|
@ -5607,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
|
||||
private fun loadEpub(
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
customDisplayName: String? = null,
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
persistToLibrary: Boolean = true
|
||||
) {
|
||||
val loadStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
|
||||
viewModelScope.launch {
|
||||
|
|
@ -5633,16 +5870,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||
|
||||
addFileToRecent(
|
||||
uri,
|
||||
FileType.EPUB,
|
||||
bookId,
|
||||
epubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
if (persistToLibrary) {
|
||||
addFileToRecent(
|
||||
uri,
|
||||
FileType.EPUB,
|
||||
bookId,
|
||||
epubBook,
|
||||
customDisplayName,
|
||||
isRecent = true,
|
||||
sourceFolderUri = null,
|
||||
bundleResult = bundleResult
|
||||
)
|
||||
}
|
||||
|
||||
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
|
||||
Timber.tag("FileOpenPerf")
|
||||
|
|
@ -7081,6 +7320,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
|
||||
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
|
||||
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
|
||||
private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
|
||||
private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
|
||||
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
|
||||
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
|
||||
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package com.aryan.reader
|
|||
|
||||
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 com.aryan.reader.data.RecentFileItem
|
||||
|
||||
@Composable
|
||||
|
|
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
|
|||
}
|
||||
}
|
||||
|
||||
item?.let { fileInfoItem ->
|
||||
if (isFileInfoVisible) {
|
||||
FileInfoDialog(
|
||||
item = fileInfoItem,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { onFileInfoVisibleChange(false) },
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(fileInfoItem.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
|
||||
},
|
||||
onOpenTags = {
|
||||
onFileInfoVisibleChange(false)
|
||||
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
|
||||
}
|
||||
)
|
||||
HydratedFileInfoDialog(
|
||||
item = item,
|
||||
isVisible = isFileInfoVisible,
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onDismiss = { onFileInfoVisibleChange(false) },
|
||||
onOpenTags = { bookId ->
|
||||
onFileInfoVisibleChange(false)
|
||||
viewModel.openTagSelection(setOf(bookId))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
||||
TagSelectionBottomSheet(
|
||||
|
|
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HydratedFileInfoDialog(
|
||||
item: RecentFileItem?,
|
||||
isVisible: Boolean,
|
||||
uiState: ReaderScreenState,
|
||||
viewModel: MainViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenTags: (String) -> Unit
|
||||
) {
|
||||
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
|
||||
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(item) {
|
||||
fileInfoItem = item
|
||||
hasResolvedFullItem = false
|
||||
}
|
||||
|
||||
LaunchedEffect(isVisible, item?.bookId) {
|
||||
if (isVisible && item != null) {
|
||||
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
|
||||
hasResolvedFullItem = true
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedItem = fileInfoItem
|
||||
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
|
||||
FileInfoDialog(
|
||||
item = resolvedItem,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = onDismiss,
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(resolvedItem.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
|
||||
},
|
||||
onOpenTags = {
|
||||
onOpenTags(resolvedItem.bookId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
Normal file
15
app/src/main/java/com/aryan/reader/ReaderFontDiagnostics.kt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.shared.detectFontVariant
|
||||
import com.aryan.reader.shared.familyFilenameSignature
|
||||
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||
|
||||
const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
|
||||
|
||||
fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
|
||||
val variant = nameWithoutExtension.detectFontVariant()
|
||||
return "name='$nameWithoutExtension' " +
|
||||
"signature='${nameWithoutExtension.familyFilenameSignature()}' " +
|
||||
"variant=$variant " +
|
||||
"variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
|
||||
}
|
||||
19
app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
Normal file
19
app/src/main/java/com/aryan/reader/ReaderPopupSizing.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun readerModalMaxHeightDp(
|
||||
screenHeightDp: Int,
|
||||
fraction: Float = 0.85f,
|
||||
verticalMarginDp: Int = 32,
|
||||
preferredMinHeightDp: Int = 220
|
||||
): Int {
|
||||
val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
|
||||
val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
|
||||
val cappedHeight = minOf(usableHeight, proportionalHeight)
|
||||
return if (usableHeight >= preferredMinHeightDp) {
|
||||
cappedHeight.coerceAtLeast(preferredMinHeightDp)
|
||||
} else {
|
||||
usableHeight
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin
|
|||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.outlined.FileOpen
|
||||
import androidx.compose.material.icons.outlined.Gavel
|
||||
import androidx.compose.material.icons.outlined.Policy
|
||||
|
|
@ -142,6 +143,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.HtmlCompat
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedLegalLinks
|
||||
import com.aryan.reader.shared.SharedLegalProfile
|
||||
import com.aryan.reader.data.BookMetadataEdit
|
||||
|
|
@ -274,6 +276,8 @@ fun ContextualTopAppBar(
|
|||
selectedItemCount: Int,
|
||||
onNavIconClick: () -> Unit,
|
||||
onInfoClick: (() -> Unit)? = null,
|
||||
onSaveClick: (() -> Unit)? = null,
|
||||
onShareClick: (() -> Unit)? = null,
|
||||
onTagClick: (() -> Unit)? = null,
|
||||
onSelectAllClick: (() -> Unit)? = null,
|
||||
onPinClick: (() -> Unit)? = null,
|
||||
|
|
@ -302,6 +306,16 @@ fun ContextualTopAppBar(
|
|||
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
|
||||
}
|
||||
}
|
||||
if (selectedItemCount == 1 && onSaveClick != null) {
|
||||
IconButton(onClick = onSaveClick) {
|
||||
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
|
||||
}
|
||||
}
|
||||
if (selectedItemCount == 1 && onShareClick != null) {
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
|
||||
}
|
||||
}
|
||||
if (onSelectAllClick != null) {
|
||||
IconButton(onClick = onSelectAllClick) {
|
||||
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
|
||||
|
|
@ -1584,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean {
|
|||
return uriString?.startsWith("opds-pse://") == true
|
||||
}
|
||||
|
||||
fun RecentFileItem.canExportOriginalFile(): Boolean {
|
||||
return uriString != null && !isOpdsStream()
|
||||
}
|
||||
|
||||
fun RecentFileItem.suggestedOriginalFileName(): String {
|
||||
val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
|
||||
val baseName = displayName
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: title?.takeIf { it.isNotBlank() }
|
||||
?: "book"
|
||||
val sanitized = baseName
|
||||
.replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
||||
.trim()
|
||||
.take(120)
|
||||
.ifBlank { "book" }
|
||||
return if (
|
||||
fallbackExtension != null &&
|
||||
!sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
|
||||
) {
|
||||
"$sanitized.$fallbackExtension"
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
|
||||
val container = if (overlay) {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
|
|||
"中文",
|
||||
"简体中文",
|
||||
)
|
||||
)
|
||||
),
|
||||
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
|
||||
)
|
||||
|
||||
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions
|
||||
|
|
|
|||
|
|
@ -25,12 +25,15 @@ import android.provider.OpenableColumns
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||
import com.aryan.reader.readerFontDiagnosticSummary
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
private const val FONTS_DIR = "custom_fonts"
|
||||
private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
|
||||
|
||||
class FontsRepository(private val context: Context) {
|
||||
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
|
||||
|
|
@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
|
|||
val contentResolver = context.contentResolver
|
||||
val originalName = getFileName(uri) ?: "unknown.ttf"
|
||||
val extension = originalName.substringAfterLast('.', "").lowercase()
|
||||
val displayName = originalName.substringBeforeLast('.')
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"import.start originalName='$originalName' extension='$extension' " +
|
||||
readerFontDiagnosticSummary(displayName)
|
||||
)
|
||||
|
||||
if (extension !in listOf("ttf", "otf", "woff2")) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||
"import.unsupported originalName='$originalName' extension='$extension'"
|
||||
)
|
||||
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
|
||||
}
|
||||
|
||||
val fontId = UUID.randomUUID().toString()
|
||||
val internalFileName = "font_${fontId}.$extension"
|
||||
val destinationFile = File(fontsDir, internalFileName)
|
||||
val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
|
||||
val internalFileName = destinationFile.name
|
||||
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destinationFile).use { output ->
|
||||
|
|
@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
val displayName = originalName.substringBeforeLast('.')
|
||||
|
||||
val entity = CustomFontEntity(
|
||||
id = fontId,
|
||||
displayName = displayName,
|
||||
|
|
@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
|
|||
)
|
||||
|
||||
fontDao.insertFont(entity)
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"import.saved displayName='$displayName' internalFileName='$internalFileName' " +
|
||||
"exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
|
||||
"path='${destinationFile.absolutePath}'"
|
||||
)
|
||||
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
|
||||
|
||||
Result.success(entity)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
|
||||
Timber.e(e, "Failed to import font")
|
||||
Result.failure(e)
|
||||
}
|
||||
|
|
@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
|
|||
fontDao.deletePermanently(fontId)
|
||||
}
|
||||
|
||||
private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
|
||||
val preferredFileName = importedFontFileName(displayName, extension)
|
||||
val preferredFile = File(fontsDir, preferredFileName)
|
||||
if (!preferredFile.exists()) return preferredFile
|
||||
|
||||
val fallbackFileName = importedFontFileName(
|
||||
displayName = "${displayName}_${fontId.take(8)}",
|
||||
extension = extension
|
||||
)
|
||||
return File(fontsDir, fallbackFileName)
|
||||
}
|
||||
|
||||
private fun getFileName(uri: Uri): String? {
|
||||
var result: String? = null
|
||||
if (uri.scheme == "content") {
|
||||
|
|
@ -152,4 +180,18 @@ class FontsRepository(private val context: Context) {
|
|||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun importedFontFileName(displayName: String, extension: String): String {
|
||||
val safeBaseName = displayName
|
||||
.replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim(' ', '.')
|
||||
.take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
|
||||
.ifBlank { "font" }
|
||||
val safeExtension = extension
|
||||
.lowercase()
|
||||
.replace(Regex("""[^a-z0-9]"""), "")
|
||||
.ifBlank { "ttf" }
|
||||
return "$safeBaseName.$safeExtension"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface RecentFileDao {
|
|||
@Upsert
|
||||
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
||||
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
||||
|
||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||
|
|
@ -45,7 +45,7 @@ interface RecentFileDao {
|
|||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||
|
||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||
|
|
|
|||
|
|
@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
|
|||
var pageTargets: List<EpubPageTarget> = emptyList()
|
||||
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
|
||||
val extractionRoot = File(extractionBasePath)
|
||||
val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
|
||||
val tocDocumentNode = tocFileItem?.let { item ->
|
||||
val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
|
||||
ncxData?.let { parseXMLFile(it) }
|
||||
}
|
||||
val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
|
||||
|
||||
if (shouldUseToc) {
|
||||
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
|
||||
val tocFileItem = manifestItems.values.firstOrNull {
|
||||
it.absPath.endsWith(".ncx", ignoreCase = true)
|
||||
}
|
||||
if (tocFileItem != null) {
|
||||
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
|
||||
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
|
||||
|
||||
if (tocFileItem != null && ncxParentDir != null) {
|
||||
if (tocDocumentNode != null) {
|
||||
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
|
||||
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
|
||||
|
|
@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
|
|||
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
|
||||
|
||||
val tableOfContents = if (shouldUseToc) {
|
||||
val tocFileItem = manifestItems.values.firstOrNull {
|
||||
it.absPath.endsWith(".ncx", ignoreCase = true)
|
||||
}
|
||||
if (tocFileItem != null) {
|
||||
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
|
||||
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
||||
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
|
||||
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
|
||||
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
|
||||
if (tocDocumentNode != null && ncxParentDir != null) {
|
||||
val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
|
||||
|
||||
if (navMapElement != null) {
|
||||
parseTableOfContents(navMapElement, ncxParentDir)
|
||||
|
|
@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
|
|||
return result
|
||||
}
|
||||
|
||||
private fun resolveTocFileItem(
|
||||
spine: Node,
|
||||
manifestItems: Map<String, EpubManifestItem>
|
||||
): EpubManifestItem? {
|
||||
spine.getAttributeValue("toc")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { tocId -> manifestItems[tocId] }
|
||||
?.let { return it }
|
||||
|
||||
return manifestItems.values.firstOrNull {
|
||||
it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
|
||||
} ?: manifestItems.values.firstOrNull {
|
||||
it.absPath.endsWith(".ncx", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(EpubParserException::class)
|
||||
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
|
|||
|
||||
val mathJaxFileName = "tex-mml-chtml.js"
|
||||
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
||||
if (!mathJaxFile.exists()) {
|
||||
if (parseContent && !mathJaxFile.exists()) {
|
||||
try {
|
||||
context.assets.open("mathjax/$mathJaxFileName").use { input ->
|
||||
FileOutputStream(mathJaxFile).use { output ->
|
||||
|
|
@ -170,32 +170,33 @@ class OdtParser(private val context: Context) {
|
|||
|
||||
try {
|
||||
if (!isFlat) {
|
||||
val zis = ZipInputStream(inputStream)
|
||||
var entry = zis.nextEntry
|
||||
var contentXmlBytes: ByteArray? = null
|
||||
var stylesXmlBytes: ByteArray? = null
|
||||
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
|
||||
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
when (entry.name) {
|
||||
"content.xml" -> contentXmlBytes = zis.readBytes()
|
||||
"styles.xml" -> stylesXmlBytes = zis.readBytes()
|
||||
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
|
||||
else -> {
|
||||
if (entry.name !in ignoredFiles) {
|
||||
val extractedFile = safeFileInRoot(extractionDir, entry.name)
|
||||
if (extractedFile != null) {
|
||||
extractedFile.parentFile?.mkdirs()
|
||||
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
|
||||
} else {
|
||||
Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
|
||||
ZipInputStream(inputStream).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
when (entry.name) {
|
||||
"content.xml" -> contentXmlBytes = zis.readBytes()
|
||||
"styles.xml" -> stylesXmlBytes = zis.readBytes()
|
||||
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
|
||||
else -> {
|
||||
if (entry.name !in ignoredFiles) {
|
||||
val extractedFile = safeFileInRoot(extractionDir, entry.name)
|
||||
if (extractedFile != null) {
|
||||
extractedFile.parentFile?.mkdirs()
|
||||
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
|
||||
} else {
|
||||
Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
|
||||
// Pre-parse styles if available
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ package com.aryan.reader.epubreader
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
|
|
@ -38,8 +36,6 @@ import android.widget.Toast
|
|||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -52,8 +48,10 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
|
@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup
|
|||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||
import com.aryan.reader.copyPlainTextToClipboard
|
||||
import com.aryan.reader.getReaderTextureDataUri
|
||||
import com.aryan.reader.readerFontDiagnosticSummary
|
||||
import com.aryan.reader.shared.detectFontVariant
|
||||
import com.aryan.reader.shared.familyFilenameSignature
|
||||
import com.aryan.reader.shared.fontWeightCssDescriptor
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||
|
|
@ -96,6 +101,7 @@ import kotlinx.coroutines.launch
|
|||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
|
|
@ -202,6 +208,49 @@ private fun getFontCssInjection(): String {
|
|||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
|
||||
if (customFontPath == null) return ""
|
||||
|
||||
val fontFile = File(customFontPath)
|
||||
val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
|
||||
val siblings = fontFile.parentFile?.listFiles()?.filter {
|
||||
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||
} ?: listOf(fontFile)
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
|
||||
"exists=${fontFile.exists()} bytes=${fontFile.length()} " +
|
||||
readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
|
||||
" siblings=${siblings.joinToString { it.name }}"
|
||||
)
|
||||
|
||||
val css = siblings.mapNotNull { sibling ->
|
||||
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||
if (variant == null) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||
"webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
|
||||
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
|
||||
)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
|
||||
val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
|
||||
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
|
||||
)
|
||||
"@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
|
||||
}.joinToString(" ")
|
||||
|
||||
val faceCount = Regex("@font-face").findAll(css).count()
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
|
||||
)
|
||||
return css
|
||||
}
|
||||
|
||||
private fun getJsToInject(context: Context): String {
|
||||
return try {
|
||||
context.assets.open("epub_reader.js").use { inputStream ->
|
||||
|
|
@ -514,6 +563,7 @@ fun ChapterWebView(
|
|||
onFootnoteRequested: (String) -> Unit,
|
||||
currentFontFamily: ReaderFont,
|
||||
customFontPath: String? = null,
|
||||
epubFontFaceCss: String = "",
|
||||
currentTextAlign: ReaderTextAlign,
|
||||
onHighlightClicked: () -> Unit,
|
||||
onAutoScrollChapterEnd: () -> Unit = {},
|
||||
|
|
@ -578,10 +628,14 @@ fun ChapterWebView(
|
|||
showExternalLinkDialog = null
|
||||
}) { Text(stringResource(R.string.action_open)) }
|
||||
TextButton(onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_link),
|
||||
text = urlToShow
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
showExternalLinkDialog = null
|
||||
}) { Text(stringResource(R.string.action_copy)) }
|
||||
}
|
||||
|
|
@ -927,13 +981,14 @@ fun ChapterWebView(
|
|||
)
|
||||
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
val customFontCss = if (customFontPath != null) {
|
||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
||||
} else ""
|
||||
val combinedCss = "$fontCss $customFontCss"
|
||||
val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
|
||||
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(separator = " ")
|
||||
val escapedCombinedCss = escapeJsString(combinedCss)
|
||||
|
||||
val injectFontJs =
|
||||
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
|
||||
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
|
||||
view?.evaluateJavascript("javascript:$injectFontJs") {
|
||||
Timber.d("CSS Injection result: $it")
|
||||
}
|
||||
|
|
@ -1125,10 +1180,10 @@ fun ChapterWebView(
|
|||
runtimeApplierState.logPending(chapterTitle)
|
||||
} else {
|
||||
val fontCss = getFontCssInjection().replace("\n", " ")
|
||||
val customFontCss = if (customFontPath != null) {
|
||||
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
|
||||
} else ""
|
||||
val combinedCss = "$fontCss $customFontCss"
|
||||
val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
|
||||
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(separator = " ")
|
||||
val fontNameForJs = if (customFontPath != null) {
|
||||
"CustomFont"
|
||||
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
|
||||
|
|
@ -1166,8 +1221,9 @@ fun ChapterWebView(
|
|||
|
||||
if (fontCssChanged) {
|
||||
runtimeApplierState.fontCss = combinedCss
|
||||
val escapedCombinedCss = escapeJsString(combinedCss)
|
||||
val injectFontJs =
|
||||
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
|
||||
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
|
||||
webView.evaluateJavascript("javascript:$injectFontJs", null)
|
||||
}
|
||||
|
||||
|
|
@ -1304,10 +1360,14 @@ fun ChapterWebView(
|
|||
) {
|
||||
PaginatedTextSelectionMenu(
|
||||
onCopy = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_text),
|
||||
text = state.selectedText
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
state.finishActionModeCallback()
|
||||
localWebViewRef?.clearFocus()
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import androidx.compose.foundation.Image
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
|
@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
import com.aryan.reader.readerModalMaxHeightDp
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -65,25 +71,28 @@ fun DictionarySettingsDialog(
|
|||
val context = LocalContext.current
|
||||
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
||||
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
|
||||
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dict_lookup_settings),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
|
@ -212,7 +221,6 @@ fun DictionarySettingsDialog(
|
|||
onSelect = onSelectSearchPackage,
|
||||
placeholder = stringResource(R.string.dict_select_app)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ package com.aryan.reader.epubreader
|
|||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioManager
|
||||
|
|
@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.copyPlainTextToClipboard
|
||||
import com.aryan.reader.BookWordReplacementsSheet
|
||||
import com.aryan.reader.BuiltInThemes
|
||||
import com.aryan.reader.MainViewModel
|
||||
|
|
@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences
|
|||
import com.aryan.reader.readerSliderBookmarkPosition
|
||||
import com.aryan.reader.readerSliderChromeColors
|
||||
import com.aryan.reader.readerSliderToggleState
|
||||
import com.aryan.reader.paginatedreader.CssParser
|
||||
import com.aryan.reader.paginatedreader.BookPaginator
|
||||
import com.aryan.reader.paginatedreader.HeaderBlock
|
||||
import com.aryan.reader.paginatedreader.IPaginator
|
||||
|
|
@ -204,7 +205,10 @@ import com.aryan.reader.paginatedreader.ParagraphBlock
|
|||
import com.aryan.reader.paginatedreader.QuoteBlock
|
||||
import com.aryan.reader.paginatedreader.TextContentBlock
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.paginatedreader.buildEpubFontFaceCss
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||
import com.aryan.reader.paginatedreader.locatorForPersistence
|
||||
import com.aryan.reader.paginatedreader.nativeVerticalChapterPageInfo
|
||||
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
|
||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import com.aryan.reader.rememberSearchState
|
||||
|
|
@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import org.jsoup.Jsoup
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
|
|
@ -1127,6 +1132,20 @@ fun EpubReaderHost(
|
|||
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
|
||||
val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
|
||||
val fontFaces = epubBook.css.flatMap { (path, content) ->
|
||||
CssParser.parse(
|
||||
cssContent = content,
|
||||
cssPath = path,
|
||||
baseFontSizeSp = 16f,
|
||||
density = 1f,
|
||||
constraints = Constraints(maxWidth = 1, maxHeight = 1),
|
||||
isDarkTheme = false,
|
||||
adaptThemeColors = false
|
||||
).fontFaces
|
||||
}
|
||||
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
|
||||
}
|
||||
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
|
||||
|
||||
var cfiToLoad by remember { mutableStateOf(initialCfi) }
|
||||
|
|
@ -1175,7 +1194,7 @@ fun EpubReaderHost(
|
|||
fun currentNativeVerticalLocator(): Locator? {
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
|
||||
return nativeVerticalLocation?.locator
|
||||
return nativeVerticalLocation?.locatorForPersistence()
|
||||
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
|
||||
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
|
||||
}
|
||||
|
|
@ -1187,6 +1206,8 @@ fun EpubReaderHost(
|
|||
keepVisible: Boolean = false
|
||||
) {
|
||||
if (locator != null) {
|
||||
nativeVerticalScrollRequest = null
|
||||
nativeVerticalProgressScrollRequest = null
|
||||
nativeVerticalLocatorScrollRequest = locator
|
||||
nativeVerticalLocatorScrollRequestId += 1L
|
||||
nativeVerticalLocatorScrollKeepVisible = keepVisible
|
||||
|
|
@ -2110,6 +2131,44 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
val nativeVerticalDisplayPageInfo = remember(
|
||||
isNativeVerticalMode,
|
||||
nativeVerticalLocation,
|
||||
nativeVerticalCurrentPage,
|
||||
nativeVerticalTotalPages,
|
||||
currentChapterIndex,
|
||||
lastKnownLocator,
|
||||
paginator
|
||||
) {
|
||||
if (!isNativeVerticalMode) {
|
||||
null
|
||||
} else {
|
||||
nativeVerticalLocation?.chapterPageInfo ?: run {
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val locationLocator = nativeVerticalLocation?.locator
|
||||
val chapterIndex = nativeVerticalLocation?.chapterIndex
|
||||
?: locationLocator?.chapterIndex
|
||||
?: lastKnownLocator?.chapterIndex
|
||||
?: currentChapterIndex
|
||||
val locatorForChapter = locationLocator
|
||||
?.takeIf { it.chapterIndex == chapterIndex }
|
||||
?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex }
|
||||
val chapterLengthChars = chapters
|
||||
.getOrNull(chapterIndex)
|
||||
?.plainTextCharacterCount()
|
||||
?: 0
|
||||
|
||||
nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset = locatorForChapter?.charOffset,
|
||||
chapterLengthChars = chapterLengthChars,
|
||||
chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex),
|
||||
compatPageIndex = nativeVerticalCurrentPage,
|
||||
chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun currentEpubSliderPage(): Int {
|
||||
return when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
|
||||
|
|
@ -4280,6 +4339,74 @@ fun EpubReaderHost(
|
|||
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
|
||||
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
|
||||
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
|
||||
val keyboardLineScrollPx = with(density) {
|
||||
(configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96)
|
||||
}
|
||||
val keyboardPageScrollPx = with(density) {
|
||||
(configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx)
|
||||
}
|
||||
|
||||
fun scrollVerticalReaderBy(deltaPx: Int) {
|
||||
if (isNativeVerticalMode) {
|
||||
nativeVerticalScrollDeltaRequestId += 1L
|
||||
nativeVerticalScrollDeltaAnimated = false
|
||||
nativeVerticalScrollDeltaRequest = deltaPx.toFloat()
|
||||
} else {
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
"window.scrollBy({ top: $deltaPx, behavior: 'smooth' });",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateReaderPage(targetPage: Int) {
|
||||
when {
|
||||
isNativeVerticalMode -> {
|
||||
val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0)
|
||||
nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage)
|
||||
}
|
||||
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
|
||||
scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx)
|
||||
}
|
||||
else -> {
|
||||
scope.launch {
|
||||
val pageCount = paginatedPagerState.pageCount
|
||||
if (pageCount <= 0) return@launch
|
||||
val page = targetPage.coerceIn(0, pageCount - 1)
|
||||
if (page != paginatedPagerState.currentPage) {
|
||||
if (isPageTurnAnimationEnabled) {
|
||||
paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700))
|
||||
} else {
|
||||
paginatedPagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateReaderPageBy(delta: Int) {
|
||||
when {
|
||||
isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta)
|
||||
currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx)
|
||||
else -> navigateReaderPage(paginatedPagerState.currentPage + delta)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateReaderBoundary(first: Boolean) {
|
||||
when {
|
||||
isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1)
|
||||
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
|
||||
val script = if (first) {
|
||||
"window.scrollTo({ top: 0, behavior: 'smooth' });"
|
||||
} else {
|
||||
"window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });"
|
||||
}
|
||||
webViewRefForTts?.evaluateJavascript(script, null)
|
||||
}
|
||||
else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -4363,6 +4490,17 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
)
|
||||
.epubReaderKeyboardNavigationHandler(
|
||||
enabled = !searchState.isSearchActive,
|
||||
renderMode = currentRenderMode,
|
||||
isRightToLeftPagination = rightToLeftPagination,
|
||||
verticalLineScrollPx = keyboardLineScrollPx,
|
||||
onVerticalScrollBy = ::scrollVerticalReaderBy,
|
||||
onNextPage = { navigateReaderPageBy(1) },
|
||||
onPreviousPage = { navigateReaderPageBy(-1) },
|
||||
onFirstPage = { navigateReaderBoundary(first = true) },
|
||||
onLastPage = { navigateReaderBoundary(first = false) }
|
||||
)
|
||||
) {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
|
|
@ -4460,6 +4598,7 @@ fun EpubReaderHost(
|
|||
},
|
||||
onLocationChanged = { location ->
|
||||
nativeVerticalLocation = location
|
||||
location.locatorForPersistence()?.let { lastKnownLocator = it }
|
||||
},
|
||||
onTap = {
|
||||
focusManager.clearFocus()
|
||||
|
|
@ -4613,6 +4752,27 @@ fun EpubReaderHost(
|
|||
""".trimIndent()
|
||||
|
||||
val chapterToRender = chapters[targetChapterIndex]
|
||||
val chapterFontFaceCss = remember(
|
||||
chapterHead,
|
||||
chapterToRender.absPath,
|
||||
epubBook.extractionBasePath
|
||||
) {
|
||||
val fontFaces = Jsoup.parse("<head>$chapterHead</head>")
|
||||
.head()
|
||||
.getElementsByTag("style")
|
||||
.flatMap { styleElement ->
|
||||
CssParser.parse(
|
||||
cssContent = styleElement.data(),
|
||||
cssPath = chapterToRender.absPath,
|
||||
baseFontSizeSp = 16f,
|
||||
density = 1f,
|
||||
constraints = Constraints(maxWidth = 1, maxHeight = 1),
|
||||
isDarkTheme = false,
|
||||
adaptThemeColors = false
|
||||
).fontFaces
|
||||
}
|
||||
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
|
||||
}
|
||||
fun isCurrentRenderedChapter(): Boolean =
|
||||
targetChapterIndex == currentChapterIndex
|
||||
|
||||
|
|
@ -4975,6 +5135,9 @@ fun EpubReaderHost(
|
|||
currentVerticalMargin = currentVerticalMargin,
|
||||
currentFontFamily = currentFontFamily,
|
||||
customFontPath = currentCustomFontPath,
|
||||
epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(separator = " "),
|
||||
currentTextAlign = currentTextAlign,
|
||||
activeTextureId = activeTextureId,
|
||||
activeTextureAlpha = activeTextureAlpha,
|
||||
|
|
@ -6057,8 +6220,8 @@ fun EpubReaderHost(
|
|||
?: "Chapter"
|
||||
|
||||
val displayPageInfo = when {
|
||||
isNativeVerticalMode && nativeVerticalTotalPages > 0 ->
|
||||
" (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)"
|
||||
isNativeVerticalMode && nativeVerticalDisplayPageInfo != null ->
|
||||
" (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})"
|
||||
currentScrollHeightValue <= 0 || isChapterParsing -> ""
|
||||
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
|
||||
}
|
||||
|
|
@ -7059,9 +7222,14 @@ fun EpubReaderHost(
|
|||
highlightToNoteCfi = null
|
||||
},
|
||||
onCopy = {
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_text),
|
||||
text = targetHighlight.text
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
highlightToNoteCfi = null
|
||||
},
|
||||
onDictionary = {
|
||||
|
|
|
|||
|
|
@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import com.aryan.reader.readerModalMaxHeightDp
|
||||
import com.aryan.reader.readerFontDiagnosticSummary
|
||||
import com.aryan.reader.supportedFontMimeTypes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.detectFontVariant
|
||||
import com.aryan.reader.shared.fontFaceSummary
|
||||
import com.aryan.reader.shared.familyFilenameSignature
|
||||
import com.aryan.reader.shared.groupByFamily
|
||||
import com.aryan.reader.shared.hasVariableWeightFace
|
||||
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -412,8 +423,64 @@ fun getComposeFontFamily(
|
|||
): FontFamily {
|
||||
if (customFontPath != null) {
|
||||
return try {
|
||||
FontFamily(Font(File(customFontPath)))
|
||||
} catch (_: Exception) {
|
||||
val baseFile = File(customFontPath)
|
||||
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
|
||||
val siblings = baseFile.parentFile?.listFiles()?.filter {
|
||||
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||
} ?: listOf(baseFile)
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"compose.custom.start basePath='${baseFile.absolutePath}' " +
|
||||
"exists=${baseFile.exists()} bytes=${baseFile.length()} " +
|
||||
readerFontDiagnosticSummary(baseFile.nameWithoutExtension) +
|
||||
" siblings=${siblings.joinToString { it.name }}"
|
||||
)
|
||||
|
||||
val seenVariants = mutableSetOf<String>()
|
||||
val fontList = siblings.flatMap { sibling ->
|
||||
try {
|
||||
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
|
||||
variableReaderFontWeights
|
||||
} else {
|
||||
listOf(variant?.weight ?: FontWeight.Normal)
|
||||
}
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"compose.custom.candidate file='${sibling.name}' " +
|
||||
readerFontDiagnosticSummary(sibling.nameWithoutExtension) +
|
||||
" style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " +
|
||||
"weights=${weights.joinToString { it.weight.toString() }}"
|
||||
)
|
||||
weights.mapNotNull { weight ->
|
||||
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
|
||||
if (seenVariants.add("${weight.weight}|$style")) {
|
||||
Font(sibling, weight, style)
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
if (fontList.isNotEmpty()) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}"
|
||||
)
|
||||
FontFamily(fontList)
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||
"compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded"
|
||||
)
|
||||
FontFamily(Font(baseFile))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'")
|
||||
FontFamily.Default
|
||||
}
|
||||
}
|
||||
|
|
@ -436,6 +503,18 @@ fun getComposeFontFamily(
|
|||
return FontFamily.Default
|
||||
}
|
||||
|
||||
private val variableReaderFontWeights = listOf(
|
||||
FontWeight.Thin,
|
||||
FontWeight.ExtraLight,
|
||||
FontWeight.Light,
|
||||
FontWeight.Normal,
|
||||
FontWeight.Medium,
|
||||
FontWeight.SemiBold,
|
||||
FontWeight.Bold,
|
||||
FontWeight.ExtraBold,
|
||||
FontWeight.Black
|
||||
)
|
||||
|
||||
fun saveReaderSettings(
|
||||
context: Context,
|
||||
fontSize: Float,
|
||||
|
|
@ -853,16 +932,56 @@ fun FontSelectionSheetContent(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
val customFontFamilies = remember(customFonts) {
|
||||
val grouped = customFonts
|
||||
.filterNot { it.isDeleted }
|
||||
.map { it.toSharedCustomFontItem() }
|
||||
.groupByFamily()
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"picker.grouped count=${grouped.size} families=${
|
||||
grouped.joinToString { family ->
|
||||
"${family.familyName} -> [" +
|
||||
family.variants.joinToString { variantItem ->
|
||||
val font = variantItem.font
|
||||
"${font.displayName}:${variantItem.variant}"
|
||||
} + "]"
|
||||
}
|
||||
}"
|
||||
)
|
||||
grouped
|
||||
}
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
items(customFonts) { fontEntity ->
|
||||
val isSelected = currentCustomFontPath == fontEntity.path
|
||||
val fontFamily = remember(fontEntity.path) {
|
||||
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
|
||||
items(customFontFamilies) { family ->
|
||||
val baseFont = family.variants.firstOrNull {
|
||||
val variant = it.variant
|
||||
variant != null &&
|
||||
variant.weight == FontWeight.Normal &&
|
||||
variant.style == androidx.compose.ui.text.font.FontStyle.Normal
|
||||
}?.font ?: family.variants.first().font
|
||||
val isSelected = family.variants.any { it.font.path == currentCustomFontPath }
|
||||
val fontFamily = remember(baseFont.path) {
|
||||
getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path)
|
||||
}
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"picker.row family='${family.familyName}' base='${baseFont.displayName}' " +
|
||||
"selected=$isSelected variants=${
|
||||
family.variants.joinToString { "${it.font.displayName}:${it.variant}" }
|
||||
}"
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(fontEntity.displayName, fontFamily = fontFamily)
|
||||
Text(family.familyName, fontFamily = fontFamily)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
buildString {
|
||||
append(family.fontFaceSummary())
|
||||
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (isSelected) {
|
||||
|
|
@ -873,7 +992,12 @@ fun FontSelectionSheetContent(
|
|||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
|
||||
modifier = Modifier.clickable {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'"
|
||||
)
|
||||
onFontSelected(ReaderFont.ORIGINAL, baseFont.path)
|
||||
},
|
||||
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
|
@ -887,6 +1011,18 @@ fun FontSelectionSheetContent(
|
|||
}
|
||||
}
|
||||
|
||||
private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem {
|
||||
return CustomFontItem(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
fileName = fileName,
|
||||
fileExtension = fileExtension,
|
||||
path = path,
|
||||
timestamp = timestamp,
|
||||
isDeleted = isDeleted
|
||||
)
|
||||
}
|
||||
|
||||
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
|
||||
|
||||
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
|
||||
|
|
@ -915,6 +1051,8 @@ fun VisualOptionsSheet(
|
|||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
|
|
@ -924,6 +1062,8 @@ fun VisualOptionsSheet(
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type
|
|||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand
|
||||
import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull
|
||||
import com.aryan.reader.RenderMode
|
||||
|
||||
@Composable
|
||||
|
|
@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler(
|
|||
}
|
||||
true
|
||||
}
|
||||
|
||||
fun Modifier.epubReaderKeyboardNavigationHandler(
|
||||
enabled: Boolean,
|
||||
renderMode: RenderMode,
|
||||
isRightToLeftPagination: Boolean,
|
||||
verticalLineScrollPx: Int,
|
||||
onVerticalScrollBy: (Int) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onFirstPage: () -> Unit,
|
||||
onLastPage: () -> Unit
|
||||
): Modifier = this.onPreviewKeyEvent { keyEvent ->
|
||||
if (!enabled) return@onPreviewKeyEvent false
|
||||
val command = androidEpubKeyCommandOrNull(
|
||||
keyCode = keyEvent.nativeKeyEvent.keyCode,
|
||||
rightToLeftPagination = isRightToLeftPagination,
|
||||
isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed
|
||||
) ?: return@onPreviewKeyEvent false
|
||||
if (keyEvent.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent when (command) {
|
||||
AndroidEpubKeyCommand.SCROLL_UP,
|
||||
AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
when (command) {
|
||||
AndroidEpubKeyCommand.SCROLL_UP -> {
|
||||
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
|
||||
onVerticalScrollBy(-verticalLineScrollPx)
|
||||
}
|
||||
AndroidEpubKeyCommand.SCROLL_DOWN -> {
|
||||
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
|
||||
onVerticalScrollBy(verticalLineScrollPx)
|
||||
}
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage()
|
||||
AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage()
|
||||
AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage()
|
||||
AndroidEpubKeyCommand.LAST_PAGE -> onLastPage()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance(
|
|||
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
|
||||
|
||||
if (!nativeChunks.isNullOrEmpty()) {
|
||||
val resumeIdx = findTtsChunkResumeIndex(
|
||||
val startChunkIndex = resolveTtsContinuationStartIndex(
|
||||
chunks = nativeChunks,
|
||||
loadedChunkCount = loadedChunkCount,
|
||||
sourceCfi = lastReadCfi,
|
||||
startOffsetInSource = currentState.startOffsetInSource,
|
||||
currentText = currentState.currentText,
|
||||
currentChunkIndexFallback = currentState.currentChunkIndex
|
||||
currentText = currentState.currentText
|
||||
)
|
||||
|
||||
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
|
||||
val startChunkIndex = resumeIdx + 1
|
||||
if (startChunkIndex != null) {
|
||||
val token = getAuthToken()
|
||||
ttsController.start(
|
||||
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex(
|
|||
return currentChunkIndexFallback.takeIf { it in chunks.indices }
|
||||
}
|
||||
|
||||
internal fun resolveTtsContinuationStartIndex(
|
||||
chunks: List<TtsChunk>,
|
||||
loadedChunkCount: Int,
|
||||
sourceCfi: String?,
|
||||
startOffsetInSource: Int,
|
||||
currentText: String?
|
||||
): Int? {
|
||||
val matchedResumeIndex = findTtsChunkResumeIndex(
|
||||
chunks = chunks,
|
||||
sourceCfi = sourceCfi,
|
||||
startOffsetInSource = startOffsetInSource,
|
||||
currentText = currentText,
|
||||
currentChunkIndexFallback = -1
|
||||
)
|
||||
|
||||
val matchedNextIndex = matchedResumeIndex?.plus(1)
|
||||
if (matchedNextIndex != null && matchedNextIndex in chunks.indices) {
|
||||
return matchedNextIndex
|
||||
}
|
||||
|
||||
return loadedChunkCount.takeIf { it in chunks.indices }
|
||||
}
|
||||
|
||||
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
|
||||
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
||||
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
internal enum class AndroidEpubKeyCommand {
|
||||
PREVIOUS_PAGE,
|
||||
NEXT_PAGE,
|
||||
SCROLL_UP,
|
||||
SCROLL_DOWN,
|
||||
FIRST_PAGE,
|
||||
LAST_PAGE
|
||||
}
|
||||
|
||||
internal fun androidEpubKeyCommandOrNull(
|
||||
keyCode: Int,
|
||||
rightToLeftPagination: Boolean = false,
|
||||
isCtrlPressed: Boolean = false
|
||||
): AndroidEpubKeyCommand? {
|
||||
if (isCtrlPressed) return null
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (rightToLeftPagination) {
|
||||
AndroidEpubKeyCommand.NEXT_PAGE
|
||||
} else {
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (rightToLeftPagination) {
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||
} else {
|
||||
AndroidEpubKeyCommand.NEXT_PAGE
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> AndroidEpubKeyCommand.SCROLL_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> AndroidEpubKeyCommand.SCROLL_DOWN
|
||||
KeyEvent.KEYCODE_PAGE_UP -> AndroidEpubKeyCommand.PREVIOUS_PAGE
|
||||
KeyEvent.KEYCODE_PAGE_DOWN -> AndroidEpubKeyCommand.NEXT_PAGE
|
||||
KeyEvent.KEYCODE_MOVE_HOME -> AndroidEpubKeyCommand.FIRST_PAGE
|
||||
KeyEvent.KEYCODE_MOVE_END -> AndroidEpubKeyCommand.LAST_PAGE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -202,6 +202,7 @@ class BookPaginator(
|
|||
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
|
||||
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
|
||||
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
|
||||
private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath)
|
||||
|
||||
private var pageCountsAreAccurate by mutableStateOf(false)
|
||||
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
|
||||
|
|
@ -385,7 +386,7 @@ class BookPaginator(
|
|||
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
|
||||
append("-ua:${userAgentStylesheet.hashCode()}")
|
||||
append("-css:${bookCss.hashCode()}")
|
||||
append("-fonts:${allFontFaces.hashCode()}")
|
||||
append("-fonts:${expandedAllFontFaces.hashCode()}")
|
||||
}
|
||||
val hash = configString.hashCode()
|
||||
return hash
|
||||
|
|
@ -872,7 +873,7 @@ class BookPaginator(
|
|||
density = density.density,
|
||||
constraintsMaxWidth = constraints.maxWidth,
|
||||
constraintsMaxHeight = constraints.maxHeight,
|
||||
fontFaces = this.allFontFaces,
|
||||
fontFaces = expandedAllFontFaces,
|
||||
styleConfigHash = currentConfigHash,
|
||||
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
|
||||
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||
import com.aryan.reader.readerFontDiagnosticSummary
|
||||
import com.aryan.reader.shared.detectFontVariant
|
||||
import com.aryan.reader.shared.familyFilenameSignature
|
||||
import com.aryan.reader.shared.fontWeightCssDescriptor
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2")
|
||||
|
||||
fun expandFontFacesWithSiblings(
|
||||
fontFaces: List<FontFaceInfo>,
|
||||
extractionPath: String
|
||||
): List<FontFaceInfo> {
|
||||
if (fontFaces.isEmpty()) return emptyList()
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'"
|
||||
)
|
||||
|
||||
val result = fontFaces.toMutableList()
|
||||
val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() }
|
||||
val extractionRoot = File(extractionPath)
|
||||
|
||||
fontFaces.forEach { fontFace ->
|
||||
val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach
|
||||
val sourceSignature = sourceFile.familyFilenameSignature()
|
||||
if (sourceSignature.isBlank()) return@forEach
|
||||
val parent = sourceFile.parentFile ?: return@forEach
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " +
|
||||
"file='${sourceFile.name}' " +
|
||||
readerFontDiagnosticSummary(sourceFile.nameWithoutExtension)
|
||||
)
|
||||
|
||||
parent.listFiles()
|
||||
?.asSequence()
|
||||
?.filter { candidate ->
|
||||
candidate.isFile &&
|
||||
candidate.extension.lowercase() in supportedEpubFontExtensions &&
|
||||
candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature
|
||||
}
|
||||
?.forEach { candidate ->
|
||||
val variant = candidate.nameWithoutExtension.detectFontVariant()
|
||||
if (variant == null) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||
"epub.siblings.skipNoVariant file='${candidate.name}' " +
|
||||
readerFontDiagnosticSummary(candidate.nameWithoutExtension)
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val src = candidate.toFontFaceSrc(extractionRoot)
|
||||
val inferred = fontFace.copy(
|
||||
src = src,
|
||||
fontWeight = variant.weight,
|
||||
fontStyle = variant.style
|
||||
)
|
||||
if (existingKeys.add(inferred.variantKey())) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant"
|
||||
)
|
||||
result += inferred
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}")
|
||||
return result
|
||||
}
|
||||
|
||||
fun buildEpubFontFaceCss(
|
||||
fontFaces: List<FontFaceInfo>,
|
||||
extractionPath: String
|
||||
): String {
|
||||
val extractionRoot = File(extractionPath)
|
||||
return expandFontFacesWithSiblings(fontFaces, extractionPath)
|
||||
.distinctBy { it.variantKey() }
|
||||
.mapNotNull { fontFace ->
|
||||
val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null
|
||||
val family = fontFace.fontFamily.cssString()
|
||||
val url = file.toURI().toString().cssUrlString()
|
||||
val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal)
|
||||
val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal"
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " +
|
||||
readerFontDiagnosticSummary(file.nameWithoutExtension)
|
||||
)
|
||||
"@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }"
|
||||
}
|
||||
.joinToString(separator = " ")
|
||||
}
|
||||
|
||||
private fun FontFaceInfo.resolvedFile(extractionRoot: File): File {
|
||||
val source = File(src)
|
||||
return if (source.isAbsolute) source else File(extractionRoot, src)
|
||||
}
|
||||
|
||||
private fun FontFaceInfo.variantKey(): String {
|
||||
return listOf(
|
||||
fontFamily.trim().lowercase(),
|
||||
src.replace('\\', '/').lowercase(),
|
||||
fontWeight?.weight ?: FontWeight.Normal.weight,
|
||||
fontStyle ?: FontStyle.Normal
|
||||
).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun File.toFontFaceSrc(extractionRoot: File): String {
|
||||
val relative = runCatching {
|
||||
extractionRoot.toPath().relativize(toPath()).toString()
|
||||
}.getOrNull()
|
||||
return relative
|
||||
?.takeIf { !it.startsWith("..") && it.isNotBlank() }
|
||||
?.replace(File.separatorChar, '/')
|
||||
?: absolutePath
|
||||
}
|
||||
|
||||
private fun File.familyFilenameSignature(): String {
|
||||
return nameWithoutExtension.familyFilenameSignature()
|
||||
}
|
||||
|
||||
private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27")
|
||||
|
|
@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.Font
|
|||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.aryan.reader.ReaderFontDiagnosticsTag
|
||||
import com.aryan.reader.readerFontDiagnosticSummary
|
||||
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
|
|
@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
|||
if (fontFaces.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
|
||||
val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath)
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'"
|
||||
)
|
||||
Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath")
|
||||
|
||||
// 1. Define a stable, global font cache directory.
|
||||
// This assumes the parent of the extraction path is a stable base directory for epubs.
|
||||
|
|
@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
|||
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
|
||||
val bookId = File(extractionPath).name.substringBeforeLast("_")
|
||||
|
||||
val fontsByFamily = fontFaces.groupBy {
|
||||
val fontsByFamily = expandedFontFaces.groupBy {
|
||||
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
|
||||
}
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.grouped families=${
|
||||
fontsByFamily.mapValues { (_, infos) ->
|
||||
infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" }
|
||||
}
|
||||
}"
|
||||
)
|
||||
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
|
||||
|
||||
return fontsByFamily.mapValues { (familyName, fontInfos) ->
|
||||
val fontList = fontInfos.mapNotNull { fontInfo ->
|
||||
val seenVariants = mutableSetOf<String>()
|
||||
val fontList = fontInfos.flatMap { fontInfo ->
|
||||
try {
|
||||
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
|
||||
var fontFile = File(extractionPath, fontInfo.src)
|
||||
var fontFile = File(fontInfo.src).let { source ->
|
||||
if (source.isAbsolute) source else File(extractionPath, fontInfo.src)
|
||||
}
|
||||
|
||||
if (!fontFile.exists()) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w(
|
||||
"native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'"
|
||||
)
|
||||
Timber.w("Font file not found at: ${fontFile.absolutePath}")
|
||||
return@mapNotNull null
|
||||
return@flatMap emptyList()
|
||||
}
|
||||
val sourceName = fontFile.nameWithoutExtension
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " +
|
||||
readerFontDiagnosticSummary(sourceName)
|
||||
)
|
||||
|
||||
// Handle WOFF2 conversion and global caching
|
||||
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
|
||||
|
|
@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
|||
if (cachedTtfFile.exists()) {
|
||||
// Use the globally cached TTF file if it exists
|
||||
fontFile = cachedTtfFile
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'"
|
||||
)
|
||||
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
|
||||
} else {
|
||||
// Convert and save the TTF to the global cache if it doesn't exist
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'"
|
||||
)
|
||||
Timber.d("Converting woff2 font: ${fontFile.name}")
|
||||
val woff2Data = fontFile.readBytes()
|
||||
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
|
||||
|
|
@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
|
|||
if (ttfData != null) {
|
||||
cachedTtfFile.writeBytes(ttfData)
|
||||
fontFile = cachedTtfFile // Use the newly created TTF file
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}"
|
||||
)
|
||||
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).e(
|
||||
"native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'"
|
||||
)
|
||||
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
|
||||
return@mapNotNull null
|
||||
return@flatMap emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Font(
|
||||
fontFile,
|
||||
fontInfo.fontWeight ?: FontWeight.Normal,
|
||||
fontInfo.fontStyle ?: FontStyle.Normal
|
||||
val weights = if (sourceName.supportsVariableWeightAxis()) {
|
||||
variableEpubFontWeights
|
||||
} else {
|
||||
listOf(fontInfo.fontWeight ?: FontWeight.Normal)
|
||||
}
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.registerPlan family='$familyName' file='${fontFile.name}' " +
|
||||
"style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}"
|
||||
)
|
||||
weights.mapNotNull { weight ->
|
||||
val style = fontInfo.fontStyle ?: FontStyle.Normal
|
||||
if (seenVariants.add("${weight.weight}|$style")) {
|
||||
Font(fontFile, weight, style)
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'")
|
||||
Timber.e(e, "Error loading font: ${fontInfo.src}")
|
||||
null
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
if (fontList.isNotEmpty()) {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).i(
|
||||
"native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}"
|
||||
)
|
||||
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
|
||||
FontFamily(fontList)
|
||||
} else {
|
||||
Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'")
|
||||
Timber.w("Could not load any font styles for family '$familyName'.")
|
||||
null
|
||||
}
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
}
|
||||
|
||||
private val variableEpubFontWeights = listOf(
|
||||
FontWeight.Thin,
|
||||
FontWeight.ExtraLight,
|
||||
FontWeight.Light,
|
||||
FontWeight.Normal,
|
||||
FontWeight.Medium,
|
||||
FontWeight.SemiBold,
|
||||
FontWeight.Bold,
|
||||
FontWeight.ExtraBold,
|
||||
FontWeight.Black
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.copyPlainTextToClipboard
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
|
|
@ -175,6 +174,7 @@ import com.aryan.reader.epubreader.UserHighlight
|
|||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
|
||||
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -192,6 +192,8 @@ import timber.log.Timber
|
|||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
|
@ -243,7 +245,8 @@ data class NativeVerticalLocation(
|
|||
val firstVisibleItemSize: Int,
|
||||
val isAtStart: Boolean,
|
||||
val isAtEnd: Boolean,
|
||||
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList()
|
||||
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList(),
|
||||
val chapterPageInfo: NativeVerticalChapterPageInfo? = null
|
||||
)
|
||||
|
||||
data class NativeVerticalVisibleTextRange(
|
||||
|
|
@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange(
|
|||
val endCharOffset: Int
|
||||
)
|
||||
|
||||
fun NativeVerticalLocation.locatorForPersistence(): Locator? {
|
||||
val visibleRange = visibleTextRanges.firstOrNull()
|
||||
return if (visibleRange != null) {
|
||||
Locator(
|
||||
chapterIndex = visibleRange.chapterIndex,
|
||||
blockIndex = visibleRange.blockIndex,
|
||||
charOffset = visibleRange.startCharOffset
|
||||
)
|
||||
} else {
|
||||
locator
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator: Boolean,
|
||||
didLocatorScroll: Boolean
|
||||
): Boolean = !hasInitialLocator && !didLocatorScroll
|
||||
|
||||
internal fun nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport: Float,
|
||||
viewportHeight: Float
|
||||
): Float = targetOffsetInViewport - (viewportHeight * 0.5f)
|
||||
|
||||
data class NativeVerticalChapterPageInfo(
|
||||
val currentPage: Int,
|
||||
val totalPages: Int
|
||||
)
|
||||
|
||||
private data class SelectionBlockKey(
|
||||
val pageIndex: Int,
|
||||
val blockIndex: Int,
|
||||
|
|
@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder(
|
|||
chapterCount: Int,
|
||||
initialChapter: Int,
|
||||
forwardCount: Int = 2,
|
||||
backwardCount: Int = 1
|
||||
backwardCount: Int = 0
|
||||
): List<Int> {
|
||||
if (chapterCount <= 0) return emptyList()
|
||||
val start = initialChapter.coerceIn(0, chapterCount - 1)
|
||||
|
|
@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount:
|
|||
.coerceIn(0f, 100f)
|
||||
}
|
||||
|
||||
internal fun nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset: Int?,
|
||||
chapterLengthChars: Int,
|
||||
chapterPageCount: Int?,
|
||||
compatPageIndex: Int,
|
||||
chapterStartPageIndex: Int?
|
||||
): NativeVerticalChapterPageInfo? {
|
||||
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
|
||||
val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) {
|
||||
((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1))
|
||||
.roundToInt()
|
||||
} else if (chapterStartPageIndex != null) {
|
||||
compatPageIndex - chapterStartPageIndex
|
||||
} else {
|
||||
0
|
||||
}.coerceIn(0, total - 1)
|
||||
return NativeVerticalChapterPageInfo(
|
||||
currentPage = pageIndexInChapter + 1,
|
||||
totalPages = total
|
||||
)
|
||||
}
|
||||
|
||||
internal fun nativeVerticalChapterPageInfoForScroll(
|
||||
itemChapterIndices: List<Int>,
|
||||
itemWeights: List<Int>,
|
||||
firstVisibleItemIndex: Int,
|
||||
firstVisibleItemScrollOffset: Int,
|
||||
firstVisibleItemSize: Int,
|
||||
chapterPageCount: Int?
|
||||
): NativeVerticalChapterPageInfo? {
|
||||
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
|
||||
if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) {
|
||||
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
|
||||
}
|
||||
val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex))
|
||||
val chapterIndex = itemChapterIndices[safeIndex]
|
||||
val chapterItems = itemChapterIndices.indices.filter { index ->
|
||||
index < itemWeights.size && itemChapterIndices[index] == chapterIndex
|
||||
}
|
||||
val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) }
|
||||
if (totalChapterWeight <= 0) {
|
||||
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
|
||||
}
|
||||
val completedWeight = chapterItems
|
||||
.filter { it < safeIndex }
|
||||
.sumOf { itemWeights[it].coerceAtLeast(0) }
|
||||
val currentWeight = itemWeights[safeIndex].coerceAtLeast(0)
|
||||
val currentFraction = if (firstVisibleItemSize > 0) {
|
||||
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
|
||||
.coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat())
|
||||
.coerceIn(0f, 1f)
|
||||
val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1)
|
||||
return NativeVerticalChapterPageInfo(
|
||||
currentPage = pageIndexInChapter + 1,
|
||||
totalPages = total
|
||||
)
|
||||
}
|
||||
|
||||
internal fun nativeVerticalProgressToItemIndex(
|
||||
itemWeights: List<Int>,
|
||||
progressPercent: Float
|
||||
|
|
@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress(
|
|||
)
|
||||
}
|
||||
|
||||
private fun estimateNativeVerticalScrollProgressPercent(
|
||||
items: List<NativeVerticalFlowItem>,
|
||||
internal fun estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights: List<Int>,
|
||||
firstVisibleItemIndex: Int,
|
||||
firstVisibleItemScrollOffset: Int,
|
||||
firstVisibleItemSize: Int
|
||||
): Float? {
|
||||
if (items.isEmpty()) return null
|
||||
val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null
|
||||
val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex)
|
||||
val completedWeight = items
|
||||
if (itemWeights.isEmpty()) return null
|
||||
val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null
|
||||
val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex)
|
||||
val completedWeight = itemWeights
|
||||
.take(safeIndex)
|
||||
.sumOf { it.locationWeight }
|
||||
val currentItem = items[safeIndex]
|
||||
.sum()
|
||||
val currentItemWeight = itemWeights[safeIndex]
|
||||
val currentFraction = if (firstVisibleItemSize > 0) {
|
||||
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
|
||||
.coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction)
|
||||
val weightedPosition = completedWeight + (currentItemWeight * currentFraction)
|
||||
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
|
||||
.toFloat()
|
||||
.coerceIn(0f, 100f)
|
||||
}
|
||||
|
||||
private fun estimateNativeVerticalScrollProgressPercent(
|
||||
items: List<NativeVerticalFlowItem>,
|
||||
firstVisibleItemIndex: Int,
|
||||
firstVisibleItemScrollOffset: Int,
|
||||
firstVisibleItemSize: Int
|
||||
): Float? {
|
||||
return estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights = items.map { it.locationWeight },
|
||||
firstVisibleItemIndex = firstVisibleItemIndex,
|
||||
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
|
||||
firstVisibleItemSize = firstVisibleItemSize
|
||||
)
|
||||
}
|
||||
|
||||
private fun findNativeVerticalFlowItemIndexForLocator(
|
||||
items: List<NativeVerticalFlowItem>,
|
||||
chapters: List<NativeVerticalFlowChapter>,
|
||||
|
|
@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges(
|
|||
|
||||
val start = blockStart + (firstVisibleOffset ?: 0)
|
||||
val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
|
||||
NativeVerticalVisibleTextRange(
|
||||
bounds.top to NativeVerticalVisibleTextRange(
|
||||
chapterIndex = chapterIndex,
|
||||
blockIndex = block.blockIndex,
|
||||
startCharOffset = start,
|
||||
|
|
@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges(
|
|||
)
|
||||
}
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
.map { it.second }
|
||||
.toList()
|
||||
}
|
||||
|
||||
|
|
@ -1903,6 +2012,37 @@ private fun imageContentScale(style: BlockStyle): ContentScale {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun nativeVerticalSvgContentFromDataUri(source: String): String? {
|
||||
if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null
|
||||
val commaIndex = source.indexOf(',')
|
||||
if (commaIndex < 0) return null
|
||||
val metadata = source.substring(0, commaIndex)
|
||||
val payload = source.substring(commaIndex + 1)
|
||||
return runCatching {
|
||||
if (metadata.contains(";base64", ignoreCase = true)) {
|
||||
String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8)
|
||||
} else {
|
||||
URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8")
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
internal fun nativeVerticalImageModelData(source: String): Any {
|
||||
val trimmed = source.trim()
|
||||
return when {
|
||||
trimmed.startsWith("<svg", ignoreCase = true) -> SvgData(trimmed)
|
||||
trimmed.startsWith("data:image/svg+xml", ignoreCase = true) ->
|
||||
nativeVerticalSvgContentFromDataUri(trimmed)?.let { SvgData(it) } ?: trimmed
|
||||
trimmed.startsWith("file:", ignoreCase = true) ||
|
||||
trimmed.startsWith("content:", ignoreCase = true) ||
|
||||
trimmed.startsWith("android.resource:", ignoreCase = true) ||
|
||||
trimmed.startsWith("http://", ignoreCase = true) ||
|
||||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed.toUri()
|
||||
trimmed.startsWith("data:", ignoreCase = true) -> trimmed
|
||||
else -> File(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableCellImageModifier(
|
||||
block: ImageBlock,
|
||||
density: Density,
|
||||
|
|
@ -2023,7 +2163,9 @@ private fun WrappingContentLayout(
|
|||
|
||||
Layout(content = {
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(),
|
||||
model = Builder(LocalContext.current)
|
||||
.data(nativeVerticalImageModelData(block.floatedImage.path))
|
||||
.build(),
|
||||
contentDescription = block.floatedImage.altText,
|
||||
contentScale = imageContentScale(block.floatedImage.style)
|
||||
)
|
||||
|
|
@ -2971,7 +3113,7 @@ fun PaginatedReaderScreen(
|
|||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@OptIn(ExperimentalSerializationApi::class, FlowPreview::class)
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Composable
|
||||
fun NativeVerticalReaderScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -3342,19 +3484,19 @@ fun NativeVerticalReaderScreen(
|
|||
)
|
||||
if (exactDelta != null) {
|
||||
val scrollDelta = if (keepVisible) {
|
||||
val viewportHeight = rootWindowBounds.height
|
||||
val comfortableTop = viewportHeight * 0.24f
|
||||
val comfortableBottom = viewportHeight * 0.76f
|
||||
if (exactDelta in comfortableTop..comfortableBottom) {
|
||||
0f
|
||||
} else {
|
||||
exactDelta - (viewportHeight * 0.38f)
|
||||
}
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = exactDelta,
|
||||
viewportHeight = rootWindowBounds.height
|
||||
)
|
||||
} else {
|
||||
exactDelta
|
||||
}
|
||||
if (abs(scrollDelta) > 1f) {
|
||||
listState.scrollBy(scrollDelta)
|
||||
if (animate) {
|
||||
listState.animateScrollBy(scrollDelta)
|
||||
} else {
|
||||
listState.scrollBy(scrollDelta)
|
||||
}
|
||||
}
|
||||
if (keepVisible || abs(exactDelta) > 1f) return true
|
||||
}
|
||||
|
|
@ -3364,7 +3506,11 @@ fun NativeVerticalReaderScreen(
|
|||
chapters = chapters,
|
||||
locator = locator
|
||||
) ?: return false
|
||||
listState.scrollToItem(targetIndex)
|
||||
if (animate) {
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
} else {
|
||||
listState.scrollToItem(targetIndex)
|
||||
}
|
||||
repeat(4) {
|
||||
withFrameNanos { }
|
||||
val refinedDelta = resolveNativeVerticalScrollDeltaForLocator(
|
||||
|
|
@ -3379,13 +3525,19 @@ fun NativeVerticalReaderScreen(
|
|||
)
|
||||
if (refinedDelta != null) {
|
||||
val scrollDelta = if (keepVisible) {
|
||||
val viewportHeight = rootWindowBounds.height
|
||||
refinedDelta - (viewportHeight * 0.38f)
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = refinedDelta,
|
||||
viewportHeight = rootWindowBounds.height
|
||||
)
|
||||
} else {
|
||||
refinedDelta
|
||||
}
|
||||
if (abs(scrollDelta) > 1f) {
|
||||
listState.scrollBy(scrollDelta)
|
||||
if (animate) {
|
||||
listState.animateScrollBy(scrollDelta)
|
||||
} else {
|
||||
listState.scrollBy(scrollDelta)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -3441,8 +3593,11 @@ fun NativeVerticalReaderScreen(
|
|||
|
||||
prefetchOrder.forEach { chapterIndex ->
|
||||
if (!isActive) return@LaunchedEffect
|
||||
while (isActive && listState.isScrollInProgress) {
|
||||
delay(80L)
|
||||
}
|
||||
loadFlowChapter(chapterIndex)
|
||||
delay(16L)
|
||||
delay(80L)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3453,9 +3608,18 @@ fun NativeVerticalReaderScreen(
|
|||
didInitialScroll = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val didScroll = scrollToFlowLocator(targetLocator, animate = false) ||
|
||||
scrollToCompatPage(initialNativePageIndex, animate = false)
|
||||
if (didScroll) {
|
||||
val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false)
|
||||
val didScroll = didLocatorScroll ||
|
||||
if (shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator = initialNativeLocator != null,
|
||||
didLocatorScroll = didLocatorScroll
|
||||
)
|
||||
) {
|
||||
scrollToCompatPage(initialNativePageIndex, animate = false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (didScroll || initialNativeLocator != null) {
|
||||
didInitialScroll = true
|
||||
}
|
||||
}
|
||||
|
|
@ -3471,7 +3635,12 @@ fun NativeVerticalReaderScreen(
|
|||
LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) {
|
||||
val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect
|
||||
if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect
|
||||
if (scrollToFlowLocator(requestedLocator, animate = false, keepVisible = scrollRequestLocatorKeepVisible)) {
|
||||
if (scrollToFlowLocator(
|
||||
locator = requestedLocator,
|
||||
animate = scrollRequestLocatorKeepVisible,
|
||||
keepVisible = scrollRequestLocatorKeepVisible
|
||||
)
|
||||
) {
|
||||
paginator.onUserScrolledTo(
|
||||
nativeVerticalCompatPageForProgress(
|
||||
estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f,
|
||||
|
|
@ -3506,6 +3675,7 @@ fun NativeVerticalReaderScreen(
|
|||
var lastReportedTotalPageCount by remember { mutableIntStateOf(0) }
|
||||
var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) }
|
||||
var lastReportedLocator by remember { mutableStateOf<Locator?>(null) }
|
||||
var lastReportedChapterPageInfo by remember { mutableStateOf<NativeVerticalChapterPageInfo?>(null) }
|
||||
var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) {
|
||||
|
|
@ -3531,7 +3701,6 @@ fun NativeVerticalReaderScreen(
|
|||
initialScrollComplete = didInitialScroll
|
||||
)
|
||||
}
|
||||
.debounce(80)
|
||||
.collectLatest { sample ->
|
||||
if (!sample.initialScrollComplete) return@collectLatest
|
||||
val total = sample.totalPageCount
|
||||
|
|
@ -3561,18 +3730,32 @@ fun NativeVerticalReaderScreen(
|
|||
}
|
||||
val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total)
|
||||
paginator.onUserScrolledTo(compatPage)
|
||||
val visibleChapterIndex = locator?.chapterIndex
|
||||
?: flowItems.getOrNull(sample.firstVisiblePageIndex)?.chapterIndex
|
||||
val chapterPageInfo = visibleChapterIndex?.let { chapterIndex ->
|
||||
nativeVerticalChapterPageInfoForScroll(
|
||||
itemChapterIndices = flowItems.map { it.chapterIndex },
|
||||
itemWeights = flowItems.map { it.locationWeight },
|
||||
firstVisibleItemIndex = sample.firstVisiblePageIndex,
|
||||
firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset,
|
||||
firstVisibleItemSize = sample.firstVisibleItemSize,
|
||||
chapterPageCount = paginator.chapterPageCounts[chapterIndex]
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
compatPage != lastReportedVisiblePage ||
|
||||
total != lastReportedTotalPageCount ||
|
||||
abs(progressPercent - lastReportedProgressPercent) >= 0.05f ||
|
||||
locator != lastReportedLocator ||
|
||||
chapterPageInfo != lastReportedChapterPageInfo ||
|
||||
visibleTextRanges != lastReportedVisibleTextRanges
|
||||
) {
|
||||
lastReportedVisiblePage = compatPage
|
||||
lastReportedTotalPageCount = total
|
||||
lastReportedProgressPercent = progressPercent
|
||||
lastReportedLocator = locator
|
||||
lastReportedChapterPageInfo = chapterPageInfo
|
||||
lastReportedVisibleTextRanges = visibleTextRanges
|
||||
onLocationChanged(
|
||||
NativeVerticalLocation(
|
||||
|
|
@ -3586,7 +3769,8 @@ fun NativeVerticalReaderScreen(
|
|||
firstVisibleItemSize = sample.firstVisibleItemSize,
|
||||
isAtStart = sample.isAtStart,
|
||||
isAtEnd = sample.isAtEnd,
|
||||
visibleTextRanges = visibleTextRanges
|
||||
visibleTextRanges = visibleTextRanges,
|
||||
chapterPageInfo = chapterPageInfo
|
||||
)
|
||||
)
|
||||
onProgressChanged(compatPage, total, progressPercent)
|
||||
|
|
@ -3644,11 +3828,14 @@ fun NativeVerticalReaderScreen(
|
|||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_text),
|
||||
text = urlToShow
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
showExternalLinkDialog = null
|
||||
}) { Text(stringResource(R.string.action_copy)) }
|
||||
}
|
||||
|
|
@ -3673,7 +3860,8 @@ fun NativeVerticalReaderScreen(
|
|||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.sharedAcceleratedLazyWheelScroll(listState),
|
||||
contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding)
|
||||
) {
|
||||
itemsIndexed(
|
||||
|
|
@ -3820,11 +4008,14 @@ fun NativeVerticalReaderScreen(
|
|||
) {
|
||||
PaginatedTextSelectionMenu(
|
||||
onCopy = {
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_text),
|
||||
text = sel.text
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
activeSelection = null
|
||||
},
|
||||
onSelectAll = null,
|
||||
|
|
@ -5806,10 +5997,14 @@ internal fun PaginatedReaderContent(
|
|||
Row(horizontalArrangement = Arrangement.End) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_link),
|
||||
text = urlToShow
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
showExternalLinkDialog = null
|
||||
}) { Text(stringResource(R.string.action_copy)) }
|
||||
TextButton(
|
||||
|
|
@ -7535,10 +7730,14 @@ internal fun PaginatedReaderContent(
|
|||
) {
|
||||
PaginatedTextSelectionMenu(
|
||||
onCopy = {
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
val copied = copyPlainTextToClipboard(
|
||||
context = context,
|
||||
label = context.getString(R.string.clip_label_copied_text),
|
||||
text = sel.text
|
||||
)
|
||||
if (!copied) {
|
||||
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
activeSelection = null
|
||||
},
|
||||
onSelectAll = null,
|
||||
|
|
@ -8107,15 +8306,15 @@ private fun RenderFlexChildBlock(
|
|||
searchHighlighted
|
||||
}
|
||||
|
||||
// Apply block specific styles (like header font weight)
|
||||
val finalStyle = if (block is HeaderBlock) {
|
||||
createHeaderTextStyle(
|
||||
val finalStyle = when (block) {
|
||||
is HeaderBlock -> createHeaderTextStyle(
|
||||
baseStyle = textStyle,
|
||||
level = block.level,
|
||||
textAlign = block.textAlign
|
||||
)
|
||||
} else {
|
||||
textStyle
|
||||
is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
|
||||
is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
|
||||
is ListItemBlock -> textStyle
|
||||
}
|
||||
|
||||
TextWithEmphasis(
|
||||
|
|
@ -8157,7 +8356,7 @@ private fun RenderFlexChildBlock(
|
|||
|
||||
if (itemMarkerImage != null) {
|
||||
val imageRequest =
|
||||
Builder(LocalContext.current).data(File(itemMarkerImage))
|
||||
Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage))
|
||||
.crossfade(true).build()
|
||||
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
||||
|
||||
|
|
@ -8227,7 +8426,7 @@ private fun RenderFlexChildBlock(
|
|||
} else if (style.width != Dp.Unspecified && style.width > 0.dp) {
|
||||
Modifier.width(style.width)
|
||||
} else {
|
||||
Modifier
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
)
|
||||
.then(
|
||||
|
|
@ -8250,7 +8449,7 @@ private fun RenderFlexChildBlock(
|
|||
)
|
||||
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true)
|
||||
model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true)
|
||||
.build(),
|
||||
contentDescription = childBlock.altText,
|
||||
modifier = imageModifier,
|
||||
|
|
@ -8338,9 +8537,7 @@ private fun RenderFlexChildBlock(
|
|||
} else if (blockInCell is ImageBlock) {
|
||||
AsyncImage(
|
||||
model = Builder(LocalContext.current).data(
|
||||
File(
|
||||
blockInCell.path
|
||||
)
|
||||
nativeVerticalImageModelData(blockInCell.path)
|
||||
).build(),
|
||||
contentDescription = blockInCell.altText,
|
||||
contentScale = imageContentScale(blockInCell.style),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -30,6 +31,8 @@ import kotlin.random.Random
|
|||
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
|
||||
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
|
||||
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
|
||||
private const val PDF_ENCRYPT_MARKER_TAIL_BYTES = 512 * 1024
|
||||
private val PDF_ENCRYPT_MARKER = "/Encrypt".toByteArray(Charsets.US_ASCII)
|
||||
|
||||
object PdfiumCoreProvider {
|
||||
val core: PdfiumCoreKt by lazy {
|
||||
|
|
@ -42,7 +45,8 @@ internal data class DocumentCacheItem(
|
|||
val pfd: ParcelFileDescriptor?,
|
||||
val totalPages: Int,
|
||||
val pageAspectRatios: List<Float>,
|
||||
val flatTableOfContents: List<TocEntry>
|
||||
val flatTableOfContents: List<TocEntry>,
|
||||
val isPasswordProtectedPdf: Boolean = false
|
||||
)
|
||||
|
||||
internal class DocumentCache(val maxSize: Int = 3) {
|
||||
|
|
@ -123,6 +127,68 @@ class PdfPrintDocumentAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun pdfBytesContainEncryptMarker(bytes: ByteArray): Boolean {
|
||||
for (index in 0..bytes.size - PDF_ENCRYPT_MARKER.size) {
|
||||
var matches = true
|
||||
for (offset in PDF_ENCRYPT_MARKER.indices) {
|
||||
if (bytes[index + offset] != PDF_ENCRYPT_MARKER[offset]) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matches && bytes.getOrNull(index + PDF_ENCRYPT_MARKER.size)?.isPdfNameDelimiter() != false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun isPdfLikelyEncryptedForPrint(context: Context, uri: Uri): Boolean {
|
||||
return try {
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||
val knownSize = pfd.statSize.takeIf { it >= 0L }
|
||||
?: runCatching { input.channel.size() }.getOrNull()?.takeIf { it >= 0L }
|
||||
val tailBytes = if (knownSize != null && knownSize > PDF_ENCRYPT_MARKER_TAIL_BYTES) {
|
||||
input.channel.position(knownSize - PDF_ENCRYPT_MARKER_TAIL_BYTES)
|
||||
input.readBytes()
|
||||
} else if (knownSize != null) {
|
||||
input.readBytes()
|
||||
} else {
|
||||
input.readLastBytes(PDF_ENCRYPT_MARKER_TAIL_BYTES)
|
||||
}
|
||||
pdfBytesContainEncryptMarker(tailBytes)
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfPrint").w(e, "Could not inspect PDF encryption marker before print")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Byte.isPdfNameDelimiter(): Boolean {
|
||||
return when (toInt().toChar()) {
|
||||
'\u0000', '\t', '\n', '\u000C', '\r', ' ', '(', ')', '<', '>', '[', ']', '{', '}', '/', '%' -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun FileInputStream.readLastBytes(maxBytes: Int): ByteArray {
|
||||
val output = ByteArrayOutputStream(maxBytes)
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (read(buffer).also { bytesRead = it } > 0) {
|
||||
if (output.size() + bytesRead <= maxBytes) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
} else {
|
||||
val combined = output.toByteArray() + buffer.copyOf(bytesRead)
|
||||
output.reset()
|
||||
output.write(combined, combined.size - maxBytes, maxBytes)
|
||||
}
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
internal fun generateShortId(): String {
|
||||
return Random.nextInt(1000, 9999).toString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String {
|
|||
|
||||
private const val PDF_TILE_SIZE_DP = 256
|
||||
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
||||
private const val PDF_TILE_SCALE_TOLERANCE = 0.03f
|
||||
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L
|
||||
private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
|
||||
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||
|
|
@ -462,7 +462,13 @@ internal fun PdfPageComposable(
|
|||
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
|
||||
|
||||
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||
val needsTilingNow = shouldRenderPdfHighResTiles(
|
||||
effectiveScale = effectiveScale,
|
||||
targetWidthPx = actualBitmapWidthPx,
|
||||
targetHeightPx = actualBitmapHeightPx,
|
||||
isVerticalScroll = isVerticalScroll,
|
||||
isActivePage = isActivePage
|
||||
)
|
||||
|
||||
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
||||
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
||||
|
|
@ -1241,7 +1247,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
if (latestShouldPauseHighResTileRendering && renderScale > 1f) {
|
||||
if (latestShouldPauseHighResTileRendering) {
|
||||
if (shouldLogTileSample) {
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " +
|
||||
|
|
@ -1260,7 +1266,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
|
||||
if (!isActive) return@collectLatest
|
||||
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
||||
if (latestShouldPauseHighResTileRendering) {
|
||||
if (shouldLogHighResTile) {
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||
|
|
@ -1323,7 +1329,7 @@ internal fun PdfPageComposable(
|
|||
)
|
||||
}
|
||||
if (!isActive) return@withLock
|
||||
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
||||
if (latestShouldPauseHighResTileRendering) {
|
||||
if (shouldLogHighResTile) {
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||
|
|
@ -1380,7 +1386,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
return@collectLatest
|
||||
}
|
||||
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
|
||||
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) {
|
||||
if (shouldLogHighResTile) {
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
|
||||
|
|
@ -4040,9 +4046,9 @@ internal fun PdfPageComposable(
|
|||
val stableTiles = remember(tiles) { StableHolder(tiles) }
|
||||
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
|
||||
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
|
||||
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering
|
||||
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow
|
||||
LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) {
|
||||
if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) {
|
||||
if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) {
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " +
|
||||
"visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " +
|
||||
|
|
@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer(
|
|||
}
|
||||
}
|
||||
|
||||
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
|
||||
if (needsTiling && shouldDrawHighResTiles) {
|
||||
if (shouldDrawHighResTiles) {
|
||||
tiles.forEach { tile ->
|
||||
if (
|
||||
tile.bitmap.isCanvasSafeBitmap(
|
||||
|
|
@ -5353,7 +5358,7 @@ private fun PdfPageRenderer(
|
|||
) {
|
||||
MagnifierComposable(
|
||||
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
|
||||
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
|
||||
tiles = if (staticData.shouldDrawHighResTiles) staticData.tiles.item else emptyList(),
|
||||
currentScale = effectiveScale,
|
||||
magnifierCenterOnBitmap = magnifierCenterTarget,
|
||||
contentWidthPx = staticData.targetWidth,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
|
|
@ -28,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.material.icons.filled.Edit
|
||||
|
|
@ -64,6 +67,7 @@ import androidx.compose.ui.geometry.Rect
|
|||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -74,6 +78,7 @@ import com.aryan.reader.R
|
|||
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.epubreader.titleRes
|
||||
import com.aryan.reader.readerModalMaxHeightDp
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
|
||||
|
||||
|
|
@ -568,6 +573,8 @@ fun PdfVisualOptionsSheet(
|
|||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
|
|
@ -577,6 +584,8 @@ fun PdfVisualOptionsSheet(
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ internal fun pdfOverflowMenuSections(
|
|||
hasHiddenToolbarTools: Boolean,
|
||||
isPro: Boolean,
|
||||
effectiveFileType: FileType,
|
||||
hasFileInfo: Boolean = true
|
||||
hasFileInfo: Boolean = true,
|
||||
canPrintDocument: Boolean = true
|
||||
): List<PdfOverflowMenuSection> = buildList {
|
||||
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
||||
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
|
||||
|
|
@ -96,7 +97,7 @@ internal fun pdfOverflowMenuSections(
|
|||
if (
|
||||
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
||||
(effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
||||
) {
|
||||
add(PdfOverflowMenuSection.FILE_ACTIONS)
|
||||
}
|
||||
|
|
@ -136,6 +137,7 @@ internal fun PdfTopBar(
|
|||
isReflowingThisBook: Boolean,
|
||||
hasReflowFile: Boolean,
|
||||
isPdfDocumentLoaded: Boolean,
|
||||
canPrintDocument: Boolean = true,
|
||||
isTabsEnabled: Boolean,
|
||||
openTabs: List<RecentFileItem>,
|
||||
activeTabBookId: String?,
|
||||
|
|
@ -395,12 +397,13 @@ internal fun PdfTopBar(
|
|||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
pdfOverflowMenuSections(
|
||||
hiddenTools = hiddenTools,
|
||||
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
||||
isPro = BuildConfig.IS_PRO,
|
||||
effectiveFileType = effectiveFileType
|
||||
effectiveFileType = effectiveFileType,
|
||||
canPrintDocument = canPrintDocument
|
||||
).forEachIndexed { index, section ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
when (section) {
|
||||
|
|
|
|||
|
|
@ -378,7 +378,6 @@ fun PdfViewerScreen(
|
|||
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
|
||||
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
|
||||
var showScreenOrientationSheet by remember { mutableStateOf(false) }
|
||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||
var isScrollLocked by remember { mutableStateOf(false) }
|
||||
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
|
||||
|
|
@ -454,6 +453,8 @@ fun PdfViewerScreen(
|
|||
val uiState by viewModel.uiState.collectAsState()
|
||||
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
|
||||
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
|
||||
var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf<String?>(null) }
|
||||
var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) }
|
||||
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
|
||||
|
||||
var showNewTabSheet by remember { mutableStateOf(false) }
|
||||
|
|
@ -598,7 +599,11 @@ fun PdfViewerScreen(
|
|||
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
|
||||
}
|
||||
|
||||
val onPrintDocument: () -> Unit = {
|
||||
val onPrintDocument: () -> Unit = onPrintDocument@{
|
||||
if (isPrintBlockedForPasswordProtectedPdf) {
|
||||
showBanner(context.getString(R.string.error_print_password_protected), isError = true)
|
||||
return@onPrintDocument
|
||||
}
|
||||
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
|
||||
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
|
||||
|
||||
|
|
@ -2423,8 +2428,9 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentPageScale) {
|
||||
if (currentPageScale != 1f) {
|
||||
val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale)
|
||||
LaunchedEffect(zoomIndicatorPercentage) {
|
||||
if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) {
|
||||
showZoomIndicator = true
|
||||
delay(1500)
|
||||
showZoomIndicator = false
|
||||
|
|
@ -3518,6 +3524,7 @@ fun PdfViewerScreen(
|
|||
isDocumentReady = false
|
||||
errorMessage = null
|
||||
documentMetadataTitle = null
|
||||
isPrintBlockedForPasswordProtectedPdf = false
|
||||
currentBookId = null
|
||||
areAnnotationsLoaded = false
|
||||
loadedSidecarBookId = null
|
||||
|
|
@ -3591,6 +3598,7 @@ fun PdfViewerScreen(
|
|||
totalPages = cachedItem.totalPages
|
||||
pageAspectRatios = cachedItem.pageAspectRatios
|
||||
flatTableOfContents = cachedItem.flatTableOfContents
|
||||
isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf
|
||||
|
||||
val mapPage = tabStateMap[currentBookId!!]
|
||||
val uiPage = uiState.initialPageInBook
|
||||
|
|
@ -3634,6 +3642,8 @@ fun PdfViewerScreen(
|
|||
|
||||
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
|
||||
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
|
||||
val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF &&
|
||||
(documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri))
|
||||
|
||||
if (!isActive) {
|
||||
doc.close()
|
||||
|
|
@ -3641,6 +3651,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
pdfDocument = doc
|
||||
isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf
|
||||
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
|
||||
|
|
@ -3737,7 +3748,8 @@ fun PdfViewerScreen(
|
|||
pfd = null,
|
||||
totalPages = pagesCount,
|
||||
pageAspectRatios = ratios,
|
||||
flatTableOfContents = flatTableOfContents
|
||||
flatTableOfContents = flatTableOfContents,
|
||||
isPasswordProtectedPdf = loadedPasswordProtectedPdf
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -4558,6 +4570,8 @@ fun PdfViewerScreen(
|
|||
val latestSpreadScale = rememberUpdatedState(currentActiveScale)
|
||||
val latestSpreadOffset = rememberUpdatedState(currentActiveOffset)
|
||||
val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp
|
||||
val spreadPageGapPx = with(density) { spreadPageGap.toPx() }
|
||||
val spreadPageCount = spreadPageIndices.size
|
||||
var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -4930,6 +4944,20 @@ fun PdfViewerScreen(
|
|||
) {
|
||||
spreadPageIndices.forEach { pageIndex ->
|
||||
key(pageIndex) {
|
||||
val spreadPageWidth = if (spreadPageCount > 1) {
|
||||
val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f }
|
||||
with(density) {
|
||||
pdfSpreadPageSlotWidth(
|
||||
containerWidth = boxMaxWidthFloat,
|
||||
containerHeight = boxMaxHeightFloat,
|
||||
pageGap = spreadPageGapPx,
|
||||
spreadPageCount = spreadPageCount,
|
||||
pageAspectRatio = pageAspectRatio
|
||||
).toDp()
|
||||
}
|
||||
} else {
|
||||
with(density) { boxMaxWidthFloat.toDp() }
|
||||
}
|
||||
val isPageBookmarked by remember(bookmarks, pageIndex) {
|
||||
derivedStateOf {
|
||||
bookmarks.any { it.pageIndex == pageIndex }
|
||||
|
|
@ -5130,7 +5158,7 @@ fun PdfViewerScreen(
|
|||
ocrHoverHighlights = stableOcrRects,
|
||||
modifier = if (spreadPageIndices.size > 1) {
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.width(spreadPageWidth)
|
||||
.fillMaxHeight()
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
|
|
@ -6278,6 +6306,7 @@ fun PdfViewerScreen(
|
|||
isReflowingThisBook = isReflowingThisBook,
|
||||
hasReflowFile = hasReflowFile,
|
||||
isPdfDocumentLoaded = pdfDocument != null,
|
||||
canPrintDocument = !isPrintBlockedForPasswordProtectedPdf,
|
||||
isTabsEnabled = isPdfTabStripVisible,
|
||||
openTabs = openTabs,
|
||||
activeTabBookId = activeTabBookId,
|
||||
|
|
@ -7100,9 +7129,8 @@ fun PdfViewerScreen(
|
|||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
val percentage = (currentPageScale * 100).roundToInt()
|
||||
ZoomPercentageIndicator(
|
||||
percentage = percentage,
|
||||
percentage = zoomIndicatorPercentage,
|
||||
onResetZoomClick = {
|
||||
resetZoomTrigger = System.currentTimeMillis()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.aryan.reader.pdf
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun resolveEraserStrokeWidth(
|
||||
isEraserOverride: Boolean,
|
||||
|
|
@ -88,6 +89,22 @@ internal fun clampPdfSpreadCameraOffset(
|
|||
)
|
||||
}
|
||||
|
||||
internal fun pdfSpreadPageSlotWidth(
|
||||
containerWidth: Float,
|
||||
containerHeight: Float,
|
||||
pageGap: Float,
|
||||
spreadPageCount: Int,
|
||||
pageAspectRatio: Float
|
||||
): Float {
|
||||
if (containerWidth <= 0f || containerHeight <= 0f || spreadPageCount <= 0) return 0f
|
||||
val safeGap = pageGap.coerceAtLeast(0f)
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val availableWidth = (containerWidth - (safeGap * (spreadPageCount - 1))).coerceAtLeast(0f)
|
||||
val maxPageWidth = availableWidth / spreadPageCount
|
||||
val heightFittedPageWidth = containerHeight * safeAspectRatio
|
||||
return heightFittedPageWidth.coerceAtMost(maxPageWidth).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
|
|
@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
|||
isZoomEnabled &&
|
||||
!isScrollLocked
|
||||
}
|
||||
|
||||
internal fun shouldRenderPdfHighResTiles(
|
||||
effectiveScale: Float,
|
||||
targetWidthPx: Int,
|
||||
targetHeightPx: Int,
|
||||
isVerticalScroll: Boolean,
|
||||
isActivePage: Boolean,
|
||||
largePageThresholdPx: Int = 3000,
|
||||
verticalScaleTolerance: Float = 0.01f
|
||||
): Boolean {
|
||||
val hasLargePage = targetWidthPx > largePageThresholdPx || targetHeightPx > largePageThresholdPx
|
||||
val isPageEligible = isVerticalScroll || isActivePage
|
||||
if (!isPageEligible) return false
|
||||
if (hasLargePage) return true
|
||||
|
||||
val safeScale = effectiveScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
return if (isVerticalScroll) {
|
||||
kotlin.math.abs(safeScale - 1f) > verticalScaleTolerance
|
||||
} else {
|
||||
safeScale > 1f
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pdfZoomIndicatorPercent(scale: Float): Int {
|
||||
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
return (safeScale * 100f).roundToInt()
|
||||
}
|
||||
|
||||
internal fun shouldShowPdfZoomIndicator(percentage: Int): Boolean = percentage != 100
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ 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.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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -69,6 +72,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
|||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.selected
|
||||
|
|
@ -84,6 +88,7 @@ import com.aryan.reader.HexInput
|
|||
import com.aryan.reader.R
|
||||
import com.aryan.reader.RgbInputColumn
|
||||
import com.aryan.reader.SpectrumBox
|
||||
import com.aryan.reader.readerModalMaxHeightDp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -151,18 +156,28 @@ fun ToolSettingsPopup(
|
|||
}
|
||||
|
||||
val circleSize = 28.dp
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxPopupHeight = readerModalMaxHeightDp(
|
||||
screenHeightDp = configuration.screenHeightDp,
|
||||
fraction = 0.8f,
|
||||
verticalMarginDp = 64,
|
||||
preferredMinHeightDp = 240
|
||||
).dp
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(360.dp)
|
||||
.padding(12.dp),
|
||||
.padding(12.dp)
|
||||
.heightIn(max = maxPopupHeight),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = Color(0xFF1E1E1E),
|
||||
shadowElevation = 12.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isEraser) {
|
||||
|
|
@ -450,15 +465,21 @@ private fun ColorPickerDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color(0xFF2C2C2C),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.padding(8.dp)
|
||||
.heightIn(max = maxDialogHeight)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -788,4 +809,4 @@ private fun StyledPropertySlider(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,17 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun stopEngineForRetryLocked() {
|
||||
Timber.w("BaseTts: Stopping current TTS utterance before retry.")
|
||||
try {
|
||||
tts?.stop()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to stop TTS during retry recovery")
|
||||
} finally {
|
||||
delay(350)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPreferredVoice() {
|
||||
if (tts == null) return
|
||||
|
||||
|
|
@ -302,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
requests.remove(utteranceId)
|
||||
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
shutdownEngineLocked()
|
||||
stopEngineForRetryLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ import com.aryan.reader.paginatedreader.TtsChunk
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun stableSortedIntSnapshot(values: Collection<Int>): List<Int> {
|
||||
return try {
|
||||
values.toTypedArray().sorted()
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(e, "Failed to snapshot TTS cache keys")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val START_TTS_COMMAND: SessionCommand
|
||||
get() = ttsSessionCommand("com.aryan.reader.tts.START")
|
||||
val STOP_TTS_COMMAND: SessionCommand
|
||||
|
|
@ -107,6 +116,7 @@ private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L
|
|||
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
|
||||
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
|
||||
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
|
||||
internal const val MAX_CHUNK_GENERATION_FAILURES = 2
|
||||
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
|
||||
|
||||
private fun ttsSessionCommand(action: String): SessionCommand {
|
||||
|
|
@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex(
|
|||
|
||||
internal fun shouldAdvanceToTtsPlaylistChunk(
|
||||
currentChunkIndex: Int,
|
||||
playlistChunkIndex: Int?
|
||||
playlistChunkIndex: Int?,
|
||||
skippedChunkIndices: Set<Int> = emptySet()
|
||||
): Boolean {
|
||||
return playlistChunkIndex == currentChunkIndex + 1
|
||||
return playlistChunkIndex == resolveNextPlayableTtsChunkIndex(
|
||||
currentChunkIndex = currentChunkIndex,
|
||||
totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2),
|
||||
skippedChunkIndices = skippedChunkIndices
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldStartTtsTransitionPrefetch(
|
||||
|
|
@ -162,6 +177,22 @@ internal fun shouldStopTtsPrefetchAfterMissingChunk(
|
|||
return !isLoaded && playlistIndex == null
|
||||
}
|
||||
|
||||
internal fun resolveNextPlayableTtsChunkIndex(
|
||||
currentChunkIndex: Int,
|
||||
totalChunks: Int,
|
||||
skippedChunkIndices: Set<Int>
|
||||
): Int? {
|
||||
if (totalChunks <= 0 || currentChunkIndex !in -1 until totalChunks) return null
|
||||
return ((currentChunkIndex + 1) until totalChunks).firstOrNull { it !in skippedChunkIndices }
|
||||
}
|
||||
|
||||
internal fun shouldGiveUpTtsChunkGeneration(
|
||||
failureCount: Int,
|
||||
maxFailures: Int = MAX_CHUNK_GENERATION_FAILURES
|
||||
): Boolean {
|
||||
return failureCount >= maxFailures
|
||||
}
|
||||
|
||||
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
|
||||
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
|
||||
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
|
||||
|
|
@ -241,6 +272,8 @@ class TtsPlaybackManager(
|
|||
private var currentAuthToken: String? = null
|
||||
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
|
||||
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
|
||||
private val skippedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
|
||||
private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap<Int, AtomicInteger>()
|
||||
|
||||
enum class TtsMode {
|
||||
CLOUD, BASE
|
||||
|
|
@ -346,7 +379,7 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
private fun cancelPrefetchWork() {
|
||||
logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex")
|
||||
logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex")
|
||||
prefetchLoopJob?.cancel()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
|
|
@ -386,7 +419,7 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
private fun cacheSnapshot(): String {
|
||||
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}"
|
||||
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${stableSortedIntSnapshot(loadedChunks)} skipped=${stableSortedIntSnapshot(skippedChunks)} audio=${stableSortedIntSnapshot(audioFiles.keys)} streams=${stableSortedIntSnapshot(chunkStreamIds.keys)} prefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)}"
|
||||
}
|
||||
|
||||
override fun onConnect(
|
||||
|
|
@ -826,6 +859,8 @@ class TtsPlaybackManager(
|
|||
this.pageIndex = pageIndex
|
||||
|
||||
loadedChunks.clear()
|
||||
skippedChunks.clear()
|
||||
chunkGenerationFailures.clear()
|
||||
lastPrefetchIndex = -1
|
||||
|
||||
_ttsState.value = TtsState(
|
||||
|
|
@ -914,7 +949,11 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
|
||||
val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1)
|
||||
val nextPlayableChunkIndex = resolveNextPlayableTtsChunkIndex(
|
||||
currentChunkIndex = currentChunkIndex,
|
||||
totalChunks = textChunks.size,
|
||||
skippedChunkIndices = skippedChunks
|
||||
)
|
||||
?: run {
|
||||
logChunkNavMain(
|
||||
"advance-next-no-target",
|
||||
|
|
@ -922,16 +961,16 @@ class TtsPlaybackManager(
|
|||
)
|
||||
return false
|
||||
}
|
||||
val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex)
|
||||
val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex)
|
||||
?: run {
|
||||
logChunkNavMain(
|
||||
"advance-next-missing-playlist-item",
|
||||
"currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex"
|
||||
"currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}"
|
||||
)
|
||||
return false
|
||||
}
|
||||
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
|
||||
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) {
|
||||
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) {
|
||||
logChunkNavWarnMain(
|
||||
"advance-next-refused-non-contiguous",
|
||||
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
|
||||
|
|
@ -940,7 +979,7 @@ class TtsPlaybackManager(
|
|||
}
|
||||
logChunkNavMain(
|
||||
"advance-next-seek",
|
||||
"currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
|
||||
"currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
|
||||
)
|
||||
player.seekTo(nextPlaylistIndex, 0L)
|
||||
return true
|
||||
|
|
@ -1028,6 +1067,8 @@ class TtsPlaybackManager(
|
|||
audioFiles.clear()
|
||||
chunkStreamIds.clear()
|
||||
loadedChunks.clear()
|
||||
skippedChunks.clear()
|
||||
chunkGenerationFailures.clear()
|
||||
|
||||
lastPrefetchIndex = -1
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
|
|
@ -1097,6 +1138,8 @@ class TtsPlaybackManager(
|
|||
val serverText = ttsAudioData.serverText
|
||||
|
||||
if ((audioFile != null || streamUri != null) && serverText != null) {
|
||||
chunkGenerationFailures.remove(startAtIndex)
|
||||
skippedChunks.remove(startAtIndex)
|
||||
if (audioFile != null) {
|
||||
audioFiles[startAtIndex] = audioFile
|
||||
}
|
||||
|
|
@ -1167,10 +1210,30 @@ class TtsPlaybackManager(
|
|||
prefetchNextChunkAudio(startAtIndex)
|
||||
}
|
||||
} else {
|
||||
val failureCount = recordChunkGenerationFailure(startAtIndex)
|
||||
logChunkNav(
|
||||
"prepare-first-failed",
|
||||
"chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||
"chunk=$startAtIndex failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||
)
|
||||
val nextPlayableChunk = resolveNextPlayableTtsChunkIndex(
|
||||
currentChunkIndex = startAtIndex,
|
||||
totalChunks = textChunks.size,
|
||||
skippedChunkIndices = skippedChunks + startAtIndex
|
||||
)
|
||||
if (shouldGiveUpTtsChunkGeneration(failureCount) && nextPlayableChunk != null) {
|
||||
skippedChunks.add(startAtIndex)
|
||||
logChunkNav(
|
||||
"prepare-first-skip-failed-chunk",
|
||||
"chunk=$startAtIndex nextChunk=$nextPlayableChunk failureCount=$failureCount"
|
||||
)
|
||||
prepareAndPlayFirstChunk(
|
||||
startAtIndex = nextPlayableChunk,
|
||||
playWhenReady = playWhenReady,
|
||||
startAtPosition = 0L,
|
||||
prefetchAfterPrepare = prefetchAfterPrepare
|
||||
)
|
||||
return
|
||||
}
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
|
||||
|
|
@ -1243,6 +1306,8 @@ class TtsPlaybackManager(
|
|||
pageIndex = null
|
||||
cancelPrefetchWork()
|
||||
loadedChunks.clear()
|
||||
skippedChunks.clear()
|
||||
chunkGenerationFailures.clear()
|
||||
|
||||
scope.launch {
|
||||
clearAudioFiles()
|
||||
|
|
@ -1434,6 +1499,10 @@ class TtsPlaybackManager(
|
|||
}
|
||||
val targetIndex = currentIndex + i
|
||||
if (targetIndex < textChunks.size) {
|
||||
if (skippedChunks.contains(targetIndex)) {
|
||||
logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
|
||||
continue
|
||||
}
|
||||
if (prefetchingJobs.containsKey(targetIndex)) {
|
||||
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
|
||||
continue
|
||||
|
|
@ -1496,6 +1565,8 @@ class TtsPlaybackManager(
|
|||
val serverText = ttsAudioData.serverText
|
||||
|
||||
if ((audioFile != null || streamUri != null) && serverText != null) {
|
||||
chunkGenerationFailures.remove(targetIndex)
|
||||
skippedChunks.remove(targetIndex)
|
||||
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
||||
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
||||
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
|
||||
|
|
@ -1560,7 +1631,11 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
|
||||
val isImmediateNextChunk = targetIndex == resolveNextPlayableTtsChunkIndex(
|
||||
currentChunkIndex = currentChunkIndex,
|
||||
totalChunks = textChunks.size,
|
||||
skippedChunkIndices = skippedChunks
|
||||
)
|
||||
|
||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
|
||||
logChunkNavMain(
|
||||
|
|
@ -1579,11 +1654,19 @@ class TtsPlaybackManager(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
val failureCount = recordChunkGenerationFailure(targetIndex)
|
||||
Timber.e("Prefetch: Failed to download chunk $targetIndex")
|
||||
logChunkNav(
|
||||
"prefetch-generate-failed",
|
||||
"targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
|
||||
)
|
||||
if (shouldGiveUpTtsChunkGeneration(failureCount)) {
|
||||
skippedChunks.add(targetIndex)
|
||||
logChunkNav(
|
||||
"prefetch-skip-failed-chunk",
|
||||
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
prefetchingJobs[targetIndex] = job
|
||||
|
|
@ -1599,6 +1682,13 @@ class TtsPlaybackManager(
|
|||
)
|
||||
return@launch
|
||||
}
|
||||
if (skippedChunks.contains(targetIndex)) {
|
||||
logChunkNav(
|
||||
"prefetch-after-join-skipped",
|
||||
"targetChunk=$targetIndex generation=$generation"
|
||||
)
|
||||
continue
|
||||
}
|
||||
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
|
||||
val playlistIndex = findPlaylistIndexForChunk(targetIndex)
|
||||
shouldStopTtsPrefetchAfterMissingChunk(
|
||||
|
|
@ -1625,6 +1715,12 @@ class TtsPlaybackManager(
|
|||
}
|
||||
}
|
||||
|
||||
private fun recordChunkGenerationFailure(chunkIndex: Int): Int {
|
||||
return chunkGenerationFailures
|
||||
.getOrPut(chunkIndex) { AtomicInteger(0) }
|
||||
.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun trackWordByWord() {
|
||||
var loopCount = 0
|
||||
while (true) {
|
||||
|
|
@ -1821,6 +1917,8 @@ class TtsPlaybackManager(
|
|||
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
|
||||
chunkStreamIds.clear() // ADDED
|
||||
loadedChunks.clear()
|
||||
skippedChunks.clear()
|
||||
chunkGenerationFailures.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,99 +22,99 @@
|
|||
</plurals>
|
||||
<plurals name="shelf_count">
|
||||
<item quantity="one">%1$d riiul</item>
|
||||
<item quantity="other">%1$d riiulid</item>
|
||||
<item quantity="other">%1$d riiulit</item>
|
||||
</plurals>
|
||||
<plurals name="dialog_permanently_delete_desc">
|
||||
<item quantity="one">Kas soovite %1$d jäädavalt kustutada valitud faili oma seadmest? Seda toimingut ei saa tagasi võtta.</item>
|
||||
<item quantity="other">Kas soovite %1$d jäädavalt kustutada teie seadmest valitud failid? Seda toimingut ei saa tagasi võtta.</item>
|
||||
<item quantity="one">Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
|
||||
<item quantity="other">Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
|
||||
</plurals>
|
||||
<plurals name="dialog_remove_recents_desc">
|
||||
<item quantity="one">Kas soovite eemaldada %1$d valitud faili viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
|
||||
<item quantity="other">Kas soovite eemaldada %1$d valitud failid viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
|
||||
<item quantity="one">Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust.</item>
|
||||
<item quantity="other">Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust.</item>
|
||||
</plurals>
|
||||
<plurals name="dialog_remove_from_shelf_desc">
|
||||
<item quantity="one">Kas soovite kindlasti eemaldada %1$d raamat \'%2$s\' riiul? Raamat jääb teie kogusse ja kuvatakse jaotises Riiulita.</item>
|
||||
<item quantity="other">Kas soovite kindlasti eemaldada %1$d raamatud \'%2$s\' riiul? Raamatud jäävad teie kogusse ja kuvatakse jaotises Riiulita.</item>
|
||||
<item quantity="one">Kas eemaldada %1$d raamat riiulilt "%2$s"? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all.</item>
|
||||
<item quantity="other">Kas eemaldada %1$d raamatut riiulilt "%2$s"? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all.</item>
|
||||
</plurals>
|
||||
<plurals name="banner_books_removed_library">
|
||||
<item quantity="one">%1$d raamat raamatukogust eemaldatud.</item>
|
||||
<item quantity="other">%1$d raamatud raamatukogust eemaldatud.</item>
|
||||
<item quantity="other">%1$d raamatut eemaldati raamatukogust.</item>
|
||||
</plurals>
|
||||
<plurals name="banner_importing_books_count">
|
||||
<item quantity="one">Importimine %1$d raamat… See ilmub peagi teie teegis.</item>
|
||||
<item quantity="other">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</item>
|
||||
<item quantity="one">Impordin %1$d raamatut… See ilmub peagi raamatukogusse.</item>
|
||||
<item quantity="other">Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse.</item>
|
||||
</plurals>
|
||||
<plurals name="banner_books_imported_library_tab">
|
||||
<item quantity="one">Imporditud %1$d raamat. Selle leiate vahekaardilt Raamatukogu.</item>
|
||||
<item quantity="other">Imporditud %1$d raamatuid. Leiate need vahekaardilt Raamatukogu.</item>
|
||||
<item quantity="one">Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu.</item>
|
||||
<item quantity="other">Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu.</item>
|
||||
</plurals>
|
||||
<plurals name="banner_books_added_to_shelf">
|
||||
<item quantity="one">%1$d raamat lisatud riiulile.</item>
|
||||
<item quantity="other">%1$d raamatud lisatud riiulile.</item>
|
||||
<item quantity="other">%1$d raamatut lisati riiulile.</item>
|
||||
</plurals>
|
||||
<plurals name="banner_books_tagged_with_tag">
|
||||
<item quantity="one">%1$d raamat sildiga "%2$s".</item>
|
||||
<item quantity="other">%1$d raamatud sildiga "%2$s".</item>
|
||||
<item quantity="other">%1$d raamatut märgiti sildiga "%2$s".</item>
|
||||
</plurals>
|
||||
<plurals name="banner_folder_removed_with_book_count">
|
||||
<item quantity="one">Eemaldatud kaust "%1$s" ja %2$d raamat rakendusest.</item>
|
||||
<item quantity="other">Eemaldatud kaust "%1$s" ja %2$d raamatud rakendusest.</item>
|
||||
<item quantity="one">Eemaldati kaust "%1$s" ja %2$d raamat rakendusest.</item>
|
||||
<item quantity="other">Eemaldati kaust "%1$s" ja %2$d raamatut rakendusest.</item>
|
||||
</plurals>
|
||||
<plurals name="folder_count">
|
||||
<item quantity="one">%1$d kausta</item>
|
||||
<item quantity="other">%1$d kaustad</item>
|
||||
<item quantity="one">%1$d kaust</item>
|
||||
<item quantity="other">%1$d kausta</item>
|
||||
</plurals>
|
||||
<plurals name="file_count">
|
||||
<item quantity="one">%1$d faili</item>
|
||||
<item quantity="other">%1$d failid</item>
|
||||
<item quantity="one">%1$d fail</item>
|
||||
<item quantity="other">%1$d faili</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_drop_import_file_count">
|
||||
<item quantity="one">Langetage import %1$d faili</item>
|
||||
<item quantity="other">Langetage import %1$d failid</item>
|
||||
<item quantity="one">Lohista importimiseks %1$d fail</item>
|
||||
<item quantity="other">Lohista importimiseks %1$d faili</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_unsupported_import_file_count">
|
||||
<item quantity="one">%1$d toetamata fail jäetakse vahele.</item>
|
||||
<item quantity="other">%1$d toetamata failid jäetakse vahele.</item>
|
||||
<item quantity="other">%1$d toetamata faili jäetakse vahele.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_importing_file_count">
|
||||
<item quantity="one">Importimine %1$d fail…</item>
|
||||
<item quantity="other">Importimine %1$d failid…</item>
|
||||
<item quantity="one">Impordin %1$d faili…</item>
|
||||
<item quantity="other">Impordin %1$d faili…</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_imported_file_count">
|
||||
<item quantity="one">Imporditud %1$d faili.</item>
|
||||
<item quantity="other">Imporditud %1$d failid.</item>
|
||||
<item quantity="one">Imporditi %1$d fail.</item>
|
||||
<item quantity="other">Imporditi %1$d faili.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_imported_file_count_reader_support_later">
|
||||
<item quantity="one">Imporditud %1$d faili. Lugeja tugi tuleb hiljem.</item>
|
||||
<item quantity="other">Imporditud %1$d failid. Lugeja tugi tuleb hiljem.</item>
|
||||
<item quantity="one">Imporditi %1$d fail. Lugeja tugi tuleb hiljem.</item>
|
||||
<item quantity="other">Imporditi %1$d faili. Lugeja tugi tuleb hiljem.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_import_failed_file_count">
|
||||
<item quantity="one">Ei saanud importida %1$d faili.</item>
|
||||
<item quantity="other">Ei saanud importida %1$d failid.</item>
|
||||
<item quantity="one">%1$d faili importimine nurjus.</item>
|
||||
<item quantity="other">%1$d faili importimine nurjus.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_skipped_file_count">
|
||||
<item quantity="one">Vahele jäetud %1$d faili.</item>
|
||||
<item quantity="other">Vahele jäetud %1$d failid.</item>
|
||||
<item quantity="one">%1$d fail jäeti vahele.</item>
|
||||
<item quantity="other">%1$d faili jäeti vahele.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_remove_folder_desc_with_book_count">
|
||||
<item quantity="one">Eemalda "%1$s" ja selle %2$d raamatut rakendusest? Ketta faile ei kustutata.</item>
|
||||
<item quantity="other">Eemalda "%1$s" ja selle %2$d raamatud rakendusest? Ketta faile ei kustutata.</item>
|
||||
<item quantity="one">Kas eemaldada "%1$s" ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata.</item>
|
||||
<item quantity="other">Kas eemaldada "%1$s" ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_folder_sync_failed_folder_count">
|
||||
<item quantity="one">Kausta sünkroonimine ebaõnnestus %1$d kausta.</item>
|
||||
<item quantity="other">Kausta sünkroonimine ebaõnnestus %1$d kaustad.</item>
|
||||
<item quantity="one">%1$d kausta sünkroonimine nurjus.</item>
|
||||
<item quantity="other">%1$d kausta sünkroonimine nurjus.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_folder_sync_skipped_folder_count">
|
||||
<item quantity="one">Kausta sünkroonimine on lõpetatud %1$d kaust jäi vahele.</item>
|
||||
<item quantity="other">Kausta sünkroonimine on lõpetatud %1$d kaustad vahele jäetud.</item>
|
||||
<item quantity="one">Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele.</item>
|
||||
<item quantity="other">Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele.</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_opds_removed_stream_book_count">
|
||||
<item quantity="one">Eemaldatud %1$d voogesitatud OPDS raamat sellest kataloogist.</item>
|
||||
<item quantity="other">Eemaldatud %1$d voogesitatud OPDS raamatud sellest kataloogist.</item>
|
||||
<item quantity="one">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat.</item>
|
||||
<item quantity="other">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut.</item>
|
||||
</plurals>
|
||||
<plurals name="tag_count">
|
||||
<item quantity="one">%1$d tag</item>
|
||||
<item quantity="other">%1$d sildid</item>
|
||||
<item quantity="one">%1$d silt</item>
|
||||
<item quantity="other">%1$d silti</item>
|
||||
</plurals>
|
||||
<plurals name="desktop_library_tab_books_count">
|
||||
<item quantity="one">Kõik raamatud %1$d</item>
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
<item quantity="other">Kaustad %1$d</item>
|
||||
</plurals>
|
||||
<plurals name="tts_cache_chunk_count_parenthetical">
|
||||
<item quantity="one">(%1$d tükk)</item>
|
||||
<item quantity="other">(%1$d tükid)</item>
|
||||
<item quantity="one">(%1$d osa)</item>
|
||||
<item quantity="other">(%1$d osa)</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@
|
|||
<string name="action_save">Salvesta</string>
|
||||
<string name="action_delete">Kustuta</string>
|
||||
<string name="action_remove">Eemalda</string>
|
||||
<string name="action_ok">Sobib</string>
|
||||
<string name="action_ok">OK</string>
|
||||
<string name="action_close">Sulge</string>
|
||||
<string name="action_add">Lisa</string>
|
||||
<string name="action_rename">Muuda nime</string>
|
||||
<string name="action_back">Tagasi</string>
|
||||
<string name="action_search">Otsi</string>
|
||||
<string name="action_clear">Selge</string>
|
||||
<string name="action_clear">Tühjenda</string>
|
||||
<string name="action_apply">Rakenda</string>
|
||||
<string name="action_enable">Luba</string>
|
||||
<string name="error_message_format">Viga: %1$s</string>
|
||||
<string name="action_go_back">Mine tagasi</string>
|
||||
<string name="tab_free">Tasuta</string>
|
||||
<string name="tab_free">Vaba</string>
|
||||
<string name="active_tabs">Aktiivsed vahelehed</string>
|
||||
<string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string>
|
||||
<string name="close_tab">Sule vahekaart</string>
|
||||
<string name="close_all_tabs">Sulgege kõik vahelehed</string>
|
||||
<string name="close_all_tabs">Sulge kõik vahelehed</string>
|
||||
<string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string>
|
||||
<string name="dialog_close_all_tabs_desc">Kas olete kindel, et soovite sulgeda kõik aktiivsed vahelehed?</string>
|
||||
<string name="legal_agreement_full">%1$s nõustute meie %2$s ja kinnitage, et olete lugenud meie %3$s.</string>
|
||||
<string name="dialog_close_all_tabs_desc">Kas sulgeda kõik aktiivsed vahelehed?</string>
|
||||
<string name="legal_agreement_full">%1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s.</string>
|
||||
<string name="legal_terms_of_service">Kasutustingimused</string>
|
||||
<string name="legal_privacy_policy">Privaatsuspoliitika</string>
|
||||
<string name="legal_licenses">Litsentsid</string>
|
||||
|
|
@ -30,58 +30,58 @@
|
|||
<string name="clear_selection">Tühjenda valik</string>
|
||||
<string name="pin_unpin">Kinnita/vabasta</string>
|
||||
<string name="info">Info</string>
|
||||
<string name="select_all">Valige Kõik</string>
|
||||
<string name="select_all">Vali kõik</string>
|
||||
<string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string>
|
||||
<string name="dialog_warning_folder_sync_delete">Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone.</string>
|
||||
<string name="dialog_warning_folder_sync_delete">Hoiatus: osa valitud üksusi on sünkroonitud kohalikust kaustast. Jätkamisel kustutatakse tegelikud failid seadme mälust.\n\nSeda toimingut ei saa tagasi võtta.</string>
|
||||
<string name="file_information">Faili teave</string>
|
||||
<string name="book_name">Raamatu nimi</string>
|
||||
<string name="copy_name">Kopeeri nimi</string>
|
||||
<string name="original_name">Algne nimi: %1$s</string>
|
||||
<string name="revert_to_original">Taastage originaal</string>
|
||||
<string name="revert_to_original">Taasta algne nimi</string>
|
||||
<string name="file_name">Faili nimi: %1$s</string>
|
||||
<string name="author">Autor</string>
|
||||
<string name="format">Vorming</string>
|
||||
<string name="size">Suurus</string>
|
||||
<string name="added">Lisatud</string>
|
||||
<string name="location">Asukoht</string>
|
||||
<string name="source_opds">Allikas: OPDS Voog</string>
|
||||
<string name="source_opds">Allikas: OPDS-voog</string>
|
||||
<string name="source_in_app">Rakendusesisene salvestusruum</string>
|
||||
<string name="internal_storage">Sisemälu</string>
|
||||
<string name="about_title">Umbes Episteme</string>
|
||||
<string name="about_version">Versioon: %1$s (Järgmine: %2$d)</string>
|
||||
<string name="empty_select_file">Valige fail</string>
|
||||
<string name="about_title">Teave Episteme kohta</string>
|
||||
<string name="about_version">Versioon: %1$s (järk: %2$d)</string>
|
||||
<string name="empty_select_file">Vali fail</string>
|
||||
<string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string>
|
||||
<string name="clear_cloud_data_desc">Kas olete kindel, et soovite kõik oma raamatuandmed pilvest jäädavalt kustutada? See kustutab uuesti sünkroonimise vältimiseks ka teie kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
|
||||
<string name="clear_cloud_data_desc">Kas kustutada kõik raamatuandmed pilvest jäädavalt? See tühjendab uuesti sünkroonimise vältimiseks ka kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
|
||||
<string name="delete_all_data">KUSTUTA KÕIK ANDMED</string>
|
||||
<string name="nav_home">Kodu</string>
|
||||
<string name="nav_library">Raamatukogu</string>
|
||||
<string name="recent_files">Viimased failid</string>
|
||||
<string name="your_library_empty">Teie raamatukogu on tühi</string>
|
||||
<string name="your_library_empty_desc">Valige lugemiseks fail või sünkroonige kohalik kaust, et raamatuid automaatselt importida.</string>
|
||||
<string name="your_library_empty">Raamatukogu on tühi</string>
|
||||
<string name="your_library_empty_desc">Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida.</string>
|
||||
<string name="no_recent_files">Viimaseid faile pole</string>
|
||||
<string name="no_recent_files_desc">Avage fail oma teegist, et seda siin näha.</string>
|
||||
<string name="no_recent_files_desc">Ava fail raamatukogust, et seda siin näha.</string>
|
||||
<string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string>
|
||||
<string name="sync_folder">Sünkrooni kaust</string>
|
||||
<string name="local_folder">Kohalik kaust</string>
|
||||
<string name="pinned">Kinnitatud</string>
|
||||
<string name="progress_complete">%1$d%% täielik</string>
|
||||
<string name="not_available_locally">Pole kohapeal saadaval</string>
|
||||
<string name="drawer_sign_in">Logige sisse rakendusega Google</string>
|
||||
<string name="drawer_sign_in">Logi Google’iga sisse</string>
|
||||
<string name="drawer_by_signing_in">Sisse logides</string>
|
||||
<string name="drawer_pro_unlocked">Episteme Pro</string>
|
||||
<string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string>
|
||||
<string name="drawer_sync_library">Sünkrooni raamatukogu</string>
|
||||
<string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string>
|
||||
<string name="drawer_backup_desc">Laadige raamatud oma sünkroonitud kaustadest üles kausta Google Drive.</string>
|
||||
<string name="drawer_backup_desc">Laadi sünkroonitud kaustade raamatud Google Drive’i üles.</string>
|
||||
<string name="drawer_custom_fonts">Kohandatud fondid</string>
|
||||
<string name="drawer_support_project">Toetage projekti</string>
|
||||
<string name="drawer_support_project">Toeta projekti</string>
|
||||
<string name="drawer_help_feedback">Abi ja tagasiside</string>
|
||||
<string name="drawer_sign_out">Logi välja</string>
|
||||
<string name="options_recent_limit">Viimaste failide limiit</string>
|
||||
<string name="options_no_limit">Piiramata</string>
|
||||
<string name="options_files_limit">%1$d failid</string>
|
||||
<string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
||||
<string name="options_clear_reflow_cache">Tühjendage reflow vahemälu</string>
|
||||
<string name="options_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
|
||||
<string name="library_title">Raamatukogu</string>
|
||||
<string name="search_placeholder">Otsi pealkirja või autorit…</string>
|
||||
<string name="filter_types">Tüübid: %1$s</string>
|
||||
|
|
@ -92,28 +92,28 @@
|
|||
<string name="tab_folders">Kaustad</string>
|
||||
<string name="tab_catalogs">Kataloogid</string>
|
||||
<string name="no_results_found">Päringule \"%1$s\" ei leitud tulemusi</string>
|
||||
<string name="library_empty_desc">Valige PDF, EPUB, MOBI või AZW3 alustamiseks oma seadmest faili.</string>
|
||||
<string name="library_empty_desc">Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail.</string>
|
||||
<string name="fab_add_file">Lisa fail</string>
|
||||
<string name="fab_new_shelf">Uus riiul</string>
|
||||
<string name="create_new_shelf">Looge uus riiul</string>
|
||||
<string name="create_new_shelf">Loo uus riiul</string>
|
||||
<string name="shelf_name_hint">Riiuli nimi</string>
|
||||
<string name="action_create">Loo</string>
|
||||
<string name="menu_rename_shelf">Nimetage riiul ümber</string>
|
||||
<string name="menu_rename_shelf">Nimeta riiul ümber</string>
|
||||
<string name="menu_delete_shelf">Kustuta riiul</string>
|
||||
<string name="fab_add_books">Lisage raamatuid</string>
|
||||
<string name="fab_add_books">Lisa raamatuid</string>
|
||||
<string name="shelf_empty">See riiul on tühi</string>
|
||||
<string name="add_to_shelf">Lisa %1$s</string>
|
||||
<string name="fab_add_count">LISA (%1$d)</string>
|
||||
<string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string>
|
||||
<string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string>
|
||||
<string name="dialog_rename_shelf">Nimetage riiul ümber</string>
|
||||
<string name="dialog_rename_shelf">Nimeta riiul ümber</string>
|
||||
<string name="dialog_delete_shelf">Kas kustutada riiul?</string>
|
||||
<string name="dialog_delete_shelf_desc">Kas soovite kindlasti kustutada faili \'%1$s\' riiul? Kõik raamatud teisaldatakse riiulitele.</string>
|
||||
<string name="dialog_delete_shelf_desc">Kas kustutada riiul "%1$s"? Kõik raamatud teisaldatakse riiulita raamatute alla.</string>
|
||||
<string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string>
|
||||
<string name="dialog_delete_shelves">Kustuta %1$s?</string>
|
||||
<string name="dialog_delete_shelves_desc">Kas soovite kindlasti kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse jaotisesse Riiulita.</string>
|
||||
<string name="sync_local_folders">Sünkroonige kohalikud kaustad</string>
|
||||
<string name="sync_folders_desc">Reaalajas teegi loomiseks ühendage kohalikud kaustad. Episteme jälgib faile ja sünkroonimise edenemist.</string>
|
||||
<string name="dialog_delete_shelves_desc">Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla.</string>
|
||||
<string name="sync_local_folders">Sünkrooni kohalikud kaustad</string>
|
||||
<string name="sync_folders_desc">Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge.</string>
|
||||
<string name="fab_add_folder">Lisa kaust</string>
|
||||
<string name="scan_all">Skanni kõik</string>
|
||||
<string name="scanning">Skannimine…</string>
|
||||
|
|
@ -126,24 +126,24 @@
|
|||
<string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string>
|
||||
<string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string>
|
||||
<string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string>
|
||||
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja kirjutamise JSON failide sünkroonimine. Eemaldage %1$s kaust ka sellest kaustast?</string>
|
||||
<string name="action_disable_keep_sync_data">Sünkrooni andmed</string>
|
||||
<string name="action_disable_remove_sync_data">Eemaldage sünkroonimisandmed</string>
|
||||
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust?</string>
|
||||
<string name="action_disable_keep_sync_data">Hoia sünkroonimisandmed</string>
|
||||
<string name="action_disable_remove_sync_data">Eemalda sünkroonimisandmed</string>
|
||||
<string name="filter_file_types">Filtreeri failitüübid</string>
|
||||
<string name="filter_file_types_desc">Valige failitüübid, mida soovite sellest kaustast sünkroonida:</string>
|
||||
<string name="filter_library">Filtri raamatukogu</string>
|
||||
<string name="filter_file_types_desc">Vali failitüübid, mida sellest kaustast sünkroonida:</string>
|
||||
<string name="filter_library">Filtreeri raamatukogu</string>
|
||||
<string name="filter_file_type">Faili tüüp</string>
|
||||
<string name="filter_source_folder">Allikakaust</string>
|
||||
<string name="filter_read_status">Loe olekut</string>
|
||||
<string name="clear_all">Kustuta kõik</string>
|
||||
<string name="filter_read_status">Lugemise olek</string>
|
||||
<string name="clear_all">Tühjenda kõik</string>
|
||||
<string name="filter_in_app_storage">Rakendusesisene salvestusruum</string>
|
||||
<string name="external_file_prompt_title">Kas salvestada fail?</string>
|
||||
<string name="external_file_prompt_desc">Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen > More Options > External File Behavior).</string>
|
||||
<string name="external_file_dont_ask">Ära\'ära küsi uuesti</string>
|
||||
<string name="external_file_keep">Hoidke raamatukogus</string>
|
||||
<string name="external_file_prompt_desc">Kas salvestada see väline fail rakenduse raamatukokku? Kui mitte, eemaldatakse see.\n\n(Seda vaikekäitumist saad igal ajal muuta: avakuva > Rohkem valikuid > Välise faili käitumine.)</string>
|
||||
<string name="external_file_dont_ask">Ära küsi uuesti</string>
|
||||
<string name="external_file_keep">Hoia raamatukogus</string>
|
||||
<string name="external_file_delete">Eemalda</string>
|
||||
<string name="external_file_behavior_ask">Küsi iga kord</string>
|
||||
<string name="external_file_behavior_keep">Hoidke alati</string>
|
||||
<string name="external_file_behavior_keep">Hoia alati</string>
|
||||
<string name="external_file_behavior_delete">Eemalda alati</string>
|
||||
<string name="options_external_file_behavior">Välise faili käitumine</string>
|
||||
<string name="fab_add_catalog">Lisa kataloog</string>
|
||||
|
|
@ -155,29 +155,29 @@
|
|||
<string name="action_unavailable">Pole saadaval</string>
|
||||
<string name="action_download">Laadi alla</string>
|
||||
<string name="download_format">Laadi alla vorming</string>
|
||||
<string name="action_stream_now">Voogesitage kohe</string>
|
||||
<string name="action_read">Lugege</string>
|
||||
<string name="action_stream_now">Voogedasta kohe</string>
|
||||
<string name="action_read">Loe</string>
|
||||
<string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string>
|
||||
<string name="publisher">VÄLJAANDJA</string>
|
||||
<string name="published">AVALDATUD</string>
|
||||
<string name="language">KEEL</string>
|
||||
<string name="synopsis">Sisukokkuvõte</string>
|
||||
<string name="edit_catalog">Redigeeri kataloogi</string>
|
||||
<string name="add_opds_catalog">Lisa OPDS Kataloog</string>
|
||||
<string name="add_opds_catalog">Lisa OPDS-kataloog</string>
|
||||
<string name="catalog_name">Kataloogi nimi</string>
|
||||
<string name="url">URL</string>
|
||||
<string name="auth_optional">Autentimine (valikuline)</string>
|
||||
<string name="username">Kasutajanimi</string>
|
||||
<string name="password">Parool</string>
|
||||
<string name="delete_catalog">Kustuta kataloog</string>
|
||||
<string name="delete_catalog_desc">Kas soovite kindlasti kustutada \'%1$s\'?</string>
|
||||
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse jäädavalt ka %1$d sellega seotud raamatute voogesitamine teie kogust.</string>
|
||||
<string name="delete_catalog_desc">Kas oled kindel, et soovid kustutada \'%1$s\'?</string>
|
||||
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut.</string>
|
||||
<string name="preset_label">Eelseadistatud</string>
|
||||
<string name="free_plan">Tasuta plaan</string>
|
||||
<string name="forever_free">Igavesti tasuta</string>
|
||||
<string name="feature_multiple_formats">Mitu vormingut</string>
|
||||
<string name="feature_multiple_formats_desc">Toed PDF, EPUB, MOBI, AZW3</string>
|
||||
<string name="feature_tts">Android Tekst kõneks</string>
|
||||
<string name="feature_multiple_formats_desc">Toetab vorminguid PDF, EPUB, MOBI ja AZW3</string>
|
||||
<string name="feature_tts">Androidi tekst kõneks</string>
|
||||
<string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string>
|
||||
<string name="feature_dict">Põhisõnastik</string>
|
||||
<string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string>
|
||||
|
|
@ -188,25 +188,25 @@
|
|||
<string name="early_access_sale">Varajase juurdepääsu müük</string>
|
||||
<string name="pro_includes">Omadused:</string>
|
||||
<string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string>
|
||||
<string name="feature_cloud_sync_desc">Hoidke kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
|
||||
<string name="feature_cloud_sync_desc">Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
|
||||
<string name="feature_summarize">Kokkuvõte</string>
|
||||
<string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string>
|
||||
<string name="feature_smart_dict">Nutikas sõnastik</string>
|
||||
<string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string>
|
||||
<string name="feature_priority">Prioriteetsete funktsioonide taotlused</string>
|
||||
<string name="feature_priority_desc">Teie ettepanekud seatakse prioriteediks</string>
|
||||
<string name="feature_priority_desc">Sinu ettepanekud seatakse prioriteediks</string>
|
||||
<string name="pro_unlocked">Pro funktsioonid on lukustamata!</string>
|
||||
<string name="sign_in_required">Sisselogimine Nõutav</string>
|
||||
<string name="verifying_purchase">Ostu kinnitamine…</string>
|
||||
<string name="existing_purchase_found">Olemasolev ost leitud</string>
|
||||
<string name="get_lifetime_access">Hankige eluaegne juurdepääs</string>
|
||||
<string name="get_lifetime_access">Hangi eluaegne juurdepääs</string>
|
||||
<string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string>
|
||||
<string name="sign_in_to_purchase">Logige sisse oma Google konto ostmiseks Episteme Pro.</string>
|
||||
<string name="sign_in_to_purchase_credits">Logige sisse oma Google konto krediidi ostmiseks.</string>
|
||||
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Teie Pro staatust värskendatakse automaatselt.</string>
|
||||
<string name="dialog_existing_purchase_desc">Sellel seadmel on juba Pro-ost, kuid see\' on lingitud teise kontoga. Pro funktsioonide taastamiseks logige sisse kontole, mida kasutati algsel ostul.</string>
|
||||
<string name="sign_in_to_purchase">Logi sisse oma Google konto ostmiseks Episteme Pro.</string>
|
||||
<string name="sign_in_to_purchase_credits">Logi sisse oma Google konto krediidi ostmiseks.</string>
|
||||
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt.</string>
|
||||
<string name="dialog_existing_purchase_desc">Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti.</string>
|
||||
<string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string>
|
||||
<string name="dialog_sign_in_required_desc">Logige sisse oma Google konto ostmiseks Episteme Pro ja avage kõik esmaklassilised funktsioonid.</string>
|
||||
<string name="dialog_sign_in_required_desc">Logi Google’i kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid.</string>
|
||||
<string name="action_not_now">Mitte praegu</string>
|
||||
<string name="action_got_it">Selge!</string>
|
||||
<string name="custom_fonts">Kohandatud fondid</string>
|
||||
|
|
@ -218,40 +218,40 @@
|
|||
<string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string>
|
||||
<string name="content_desc_already_downloaded">Juba alla laaditud</string>
|
||||
<string name="no_custom_fonts">Kohandatud fonte pole</string>
|
||||
<string name="import_fonts_desc">Importige TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
|
||||
<string name="import_fonts_desc">Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
|
||||
<string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string>
|
||||
<string name="dialog_delete_font">Kas kustutada font?</string>
|
||||
<string name="dialog_delete_font_desc">Kas soovite kindlasti kustutada \'%1$s\'? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist teie seadmetest.</string>
|
||||
<string name="dialog_delete_font_desc">Kas kustutada "%1$s"? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest.</string>
|
||||
<string name="dialog_delete_fonts">Kas kustutada fondid?</string>
|
||||
<string name="dialog_delete_fonts_desc">Kas soovite kindlasti kustutada %1$d valitud fonte? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist teie seadmetest.</string>
|
||||
<string name="dialog_delete_fonts_desc">Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest.</string>
|
||||
<string name="get_in_touch">Võtke ühendust</string>
|
||||
<string name="feedback_desc">Kas leidsite vea, teil on funktsioonitaotlus või soovite lihtsalt tere öelda? Andke meile teada GitHubis või saatke meile e-kiri.</string>
|
||||
<string name="feedback_desc">Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri.</string>
|
||||
<string name="github_issues">GitHubi probleemid</string>
|
||||
<string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string>
|
||||
<string name="email_support">Meili tugi</string>
|
||||
<string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string>
|
||||
<string name="support_project_title">Toetage projekti</string>
|
||||
<string name="support_project_title">Toeta projekti</string>
|
||||
<string name="support_project_heading">Aidake hoida Episteme liigub</string>
|
||||
<string name="support_project_desc">Teie tugi aitab mul hoida ja täiustada Episteme kõigile!!!</string>
|
||||
<string name="support_project_desc">Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada.</string>
|
||||
<string name="support_github_sponsor">Sponsor GitHubis</string>
|
||||
<string name="support_github_sponsor_desc">Toetage arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
|
||||
<string name="support_github_sponsor_desc">Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
|
||||
<string name="support_patreon">Liituge Patreoniga</string>
|
||||
<string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string>
|
||||
<string name="dialog_unlock_pro">Avage Episteme Pro</string>
|
||||
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro funktsioon. Avage kõik professionaalsed funktsioonid ühe ühekordse ostuga.</string>
|
||||
<string name="dialog_unlock_pro">Ava Episteme Pro</string>
|
||||
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga.</string>
|
||||
<string name="action_upgrade">Uuendage</string>
|
||||
<string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string>
|
||||
<string name="dialog_confirm_sign_out_desc">Kas olete kindel, et soovite välja logida?</string>
|
||||
<string name="dialog_confirm_sign_out_desc">Kas logida välja?</string>
|
||||
<string name="device_limit_reached">Seadme limiit on saavutatud</string>
|
||||
<string name="device_limit_reached_desc">Kasutamiseks Episteme Pro selles seadmes eemaldage üks oma olemasolevatest registreeritud seadmetest.</string>
|
||||
<string name="device_limit_reached_desc">Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest.</string>
|
||||
<string name="last_seen">Viimati nähtud: %1$s</string>
|
||||
<string name="dialog_destructive_action">Kinnitage hävitav tegevus</string>
|
||||
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik teie raamatud ja lugemise edenemine sellest seadmest JA teie seadmest Google Drive konto. Seda toimingut ei saa tagasi võtta. Oled sa kindel?</string>
|
||||
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik raamatud ja lugemisjärje sellest seadmest ning Google Drive’i kontolt. Seda toimingut ei saa tagasi võtta. Kas jätkata?</string>
|
||||
<string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string>
|
||||
<string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string>
|
||||
<string name="action_confirm_clear">Kinnita ja kustuta</string>
|
||||
<string name="dialog_clear_reflow_cache">Tühjendage reflow vahemälu</string>
|
||||
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud \'Tekstivaade\' PDF-ide versioonid ja tühjendage nendega seotud pildid/HTML-i vahemälu. Teie algsed PDF-id jäävad puutumata.</string>
|
||||
<string name="dialog_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
|
||||
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud PDF-ide tekstivaate versioonid ja tühjendab nendega seotud piltide/HTML-i vahemälu. Algsed PDF-id jäävad puutumata.</string>
|
||||
<string name="tooltip_back">Tagasi</string>
|
||||
<string name="tooltip_dictionary">Sõnastik</string>
|
||||
<string name="tooltip_more_options">Rohkem valikuid</string>
|
||||
|
|
@ -267,7 +267,7 @@
|
|||
<string name="tooltip_dark_mode_on">Luba tume režiim</string>
|
||||
<string name="tooltip_dark_mode_off">Keela tume režiim</string>
|
||||
<string name="tooltip_lock_pan">Lukusta panoraam</string>
|
||||
<string name="tooltip_unlock_pan">Avage panoraam</string>
|
||||
<string name="tooltip_unlock_pan">Ava panoraamimine</string>
|
||||
<string name="tooltip_fullscreen">Täisekraan</string>
|
||||
<string name="tooltip_highlights">Kuva esiletõstmised</string>
|
||||
<string name="tooltip_highlights_off">Peida esiletõstmised</string>
|
||||
|
|
@ -280,37 +280,37 @@
|
|||
<string name="tooltip_prev_result">Eelmine tulemus</string>
|
||||
<string name="tooltip_next_result">Järgmine tulemus</string>
|
||||
<string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string>
|
||||
<string name="tooltip_dictionary_desc">Valige sõnade otsimiseks eelistatud rakendus</string>
|
||||
<string name="tooltip_dictionary_desc">Vali sõnade otsimiseks eelistatud rakendus</string>
|
||||
<string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string>
|
||||
<string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string>
|
||||
<string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string>
|
||||
<string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string>
|
||||
<string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string>
|
||||
<string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string>
|
||||
<string name="tooltip_tts_start_desc">Lugege raamatut ette oma seadme\'s häälemootori abil</string>
|
||||
<string name="tooltip_tts_start_desc">Loe raamatut ette oma seadme\'s häälemootori abil</string>
|
||||
<string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string>
|
||||
<string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string>
|
||||
<string name="tooltip_tts_resume_desc">Jätkake peatatud ettelugemisega taasesitust</string>
|
||||
<string name="tooltip_tts_resume_desc">Jätka peatatud ettelugemist</string>
|
||||
<string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string>
|
||||
<string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string>
|
||||
<string name="tooltip_lock_pan_desc">Lukustage lehel horisontaalne panoraam</string>
|
||||
<string name="tooltip_unlock_pan_desc">Avage panoraam, et uuesti lubada suumimiseks ja lohistamiseks kokkusurutud liigutused</string>
|
||||
<string name="tooltip_lock_pan_desc">Lukusta lehel horisontaalne panoraamimine</string>
|
||||
<string name="tooltip_unlock_pan_desc">Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused</string>
|
||||
<string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string>
|
||||
<string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string>
|
||||
<string name="tooltip_highlights_off_desc">Eemaldage lehelt valitav tekstiülekate</string>
|
||||
<string name="tooltip_highlights_off_desc">Eemalda lehelt valitav tekstiülekate</string>
|
||||
<string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string>
|
||||
<string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string>
|
||||
<string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string>
|
||||
<string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string>
|
||||
<string name="tooltip_show_results_desc">Laiendage paneeli, et näha kõiki otsingu vasteid</string>
|
||||
<string name="tooltip_hide_results_desc">Ahendage otsingutulemuste paneel</string>
|
||||
<string name="tooltip_show_results_desc">Laienda paneeli, et näha kõiki otsinguvastuseid</string>
|
||||
<string name="tooltip_hide_results_desc">Ahenda otsingutulemuste paneel</string>
|
||||
<string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string>
|
||||
<string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string>
|
||||
<string name="action_sign_in">Logi sisse</string>
|
||||
<string name="action_select_folder">Valige kaust</string>
|
||||
<string name="action_select">Valige</string>
|
||||
<string name="action_select_folder">Vali kaust</string>
|
||||
<string name="action_select">Vali</string>
|
||||
<string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string>
|
||||
<string name="error_folder_selection_unsupported">Teie seade\' ei toeta kaustade valikut. Saate endiselt faile ükshaaval importida.</string>
|
||||
<string name="error_folder_selection_unsupported">Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida.</string>
|
||||
<string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string>
|
||||
<string name="banner_downloaded">Allalaaditud %1$s</string>
|
||||
<string name="filter_facet">%1$s: %2$s</string>
|
||||
|
|
@ -323,7 +323,7 @@
|
|||
<string name="error_purchase_general">Ostmisel ilmnes viga.</string>
|
||||
<string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string>
|
||||
<string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string>
|
||||
<string name="banner_device_removed">See seade eemaldati teie kontolt.</string>
|
||||
<string name="banner_device_removed">See seade eemaldati sinu kontolt.</string>
|
||||
<string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string>
|
||||
<string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string>
|
||||
<string name="banner_saving_pdf">Säästmine PDF…</string>
|
||||
|
|
@ -332,9 +332,15 @@
|
|||
<string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string>
|
||||
<string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string>
|
||||
<string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string>
|
||||
<string name="banner_saving_original_file">Algse faili salvestamine…</string>
|
||||
<string name="banner_original_file_saved">Algne fail salvestati.</string>
|
||||
<string name="error_saving_file">Faili salvestamisel tekkis viga: %1$s</string>
|
||||
<string name="share_subject">Jagamine: %1$s</string>
|
||||
<string name="share_chooser_title">Jaga PDF</string>
|
||||
<string name="share_file_chooser_title">Jaga faili</string>
|
||||
<string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string>
|
||||
<string name="error_copy_to_clipboard">Lõikelauale kopeerimine nurjus</string>
|
||||
<string name="error_print_password_protected">Parooliga kaitstud PDF-faile ei saa printida</string>
|
||||
<string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string>
|
||||
<string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string>
|
||||
<string name="banner_folder_added">Lisatud kaust: %1$s</string>
|
||||
|
|
@ -360,7 +366,7 @@
|
|||
<string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string>
|
||||
<string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string>
|
||||
<string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string>
|
||||
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logige sisse.</string>
|
||||
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logi sisse.</string>
|
||||
<string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string>
|
||||
<string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string>
|
||||
<string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string>
|
||||
|
|
@ -386,7 +392,7 @@
|
|||
<string name="search_no_results_simple">Tulemusi ei leitud.</string>
|
||||
<string name="generating_summary">Kokkuvõtte genereerimine…</string>
|
||||
<string name="action_stop">Peatus</string>
|
||||
<string name="action_read_aloud">Lugege ette</string>
|
||||
<string name="action_read_aloud">Loe ette</string>
|
||||
<string name="action_copy">Kopeeri</string>
|
||||
<string name="action_copy_thread">Kopeeri lõim</string>
|
||||
<string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string>
|
||||
|
|
@ -404,7 +410,7 @@
|
|||
<string name="tts_device_voice_settings">Seadme hääleseaded</string>
|
||||
<string name="content_desc_close_settings">Sulgege seaded</string>
|
||||
<string name="tts_system_default">Süsteemi vaikeseade</string>
|
||||
<string name="tts_system_default_desc">Vastab teie Android süsteemi seaded</string>
|
||||
<string name="tts_system_default_desc">Vastab sinu Android süsteemi seaded</string>
|
||||
<string name="content_desc_selected">Valitud</string>
|
||||
<string name="tts_loading_voices">Häälte laadimine…</string>
|
||||
<string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string>
|
||||
|
|
@ -450,7 +456,7 @@
|
|||
<string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string>
|
||||
<string name="dict_fallback_app">Varurakendus</string>
|
||||
<string name="dict_dictionary_app">Sõnastiku rakendus</string>
|
||||
<string name="dict_select_app">Valige rakendus</string>
|
||||
<string name="dict_select_app">Vali rakendus</string>
|
||||
<string name="dict_translate">Tõlgi</string>
|
||||
<string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string>
|
||||
<string name="dict_search_app">Otsi rakendust</string>
|
||||
|
|
@ -466,16 +472,16 @@
|
|||
<string name="ai_generating_recap">Kokkuvõtte genereerimine…</string>
|
||||
<string name="ai_chapter_summary">Peatüki kokkuvõte</string>
|
||||
<string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string>
|
||||
<string name="ai_unlock_summarization">Avage peatüki kokkuvõte</string>
|
||||
<string name="ai_unlock_summarization">Ava peatüki kokkuvõte</string>
|
||||
<string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
|
||||
<string name="action_learn_more">Lisateave</string>
|
||||
<string name="ai_unlock_smart_dict">Avage nutikas sõnaraamat</string>
|
||||
<string name="ai_unlock_smart_dict">Ava nutikas sõnastik</string>
|
||||
<string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string>
|
||||
<string name="content_desc_bookmark_icon">Järjehoidja</string>
|
||||
<string name="content_desc_selected_slot">Valitud pesa</string>
|
||||
<string name="dialog_customize_palette">Kohandage palett</string>
|
||||
<string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string>
|
||||
<string name="palette_select_color_for_slot">Valige pesa värv:</string>
|
||||
<string name="palette_select_color_for_slot">Vali pesa värv:</string>
|
||||
<string name="chapter_empty">See peatükk on tühi.</string>
|
||||
<string name="chapter_not_found">Peatükki ei leitud</string>
|
||||
<string name="error_loading_chapter">Viga peatüki laadimisel</string>
|
||||
|
|
@ -496,7 +502,7 @@
|
|||
<string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string>
|
||||
<string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string>
|
||||
<string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string>
|
||||
<string name="menu_keep_screen_on">Hoidke ekraan sees</string>
|
||||
<string name="menu_keep_screen_on">Hoia ekraan sees</string>
|
||||
<string name="menu_visual_options">Visuaalsed valikud</string>
|
||||
<string name="menu_screen_orientation">Ekraani suund</string>
|
||||
<string name="menu_change_reading_mode">Muutke lugemisrežiimi</string>
|
||||
|
|
@ -526,7 +532,7 @@
|
|||
<string name="content_desc_start_playback">Mängi</string>
|
||||
<string name="auto_scroll_local_speed">Kohalik kiirus</string>
|
||||
<string name="auto_scroll_global_speed">Globaalne kiirus</string>
|
||||
<string name="content_desc_select_mode">Valige Režiim</string>
|
||||
<string name="content_desc_select_mode">Vali režiim</string>
|
||||
<string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string>
|
||||
<string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string>
|
||||
<string name="content_desc_disable_musician_mode">Keela muusiku režiim</string>
|
||||
|
|
@ -550,25 +556,25 @@
|
|||
<string name="action_locate">Otsige üles</string>
|
||||
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
|
||||
<string name="no_images_found">Pilte ei leitud.</string>
|
||||
<string name="content_desc_download_image">Laadige pilt alla</string>
|
||||
<string name="content_desc_download_image">Laadi pilt alla</string>
|
||||
<string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string>
|
||||
<string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string>
|
||||
<string name="label_new_name">Uus nimi</string>
|
||||
<string name="label_new_title">Uus pealkiri</string>
|
||||
<string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string>
|
||||
<string name="dialog_delete_bookmark_desc">Kas olete kindel, et soovite selle järjehoidja jäädavalt kustutada?</string>
|
||||
<string name="dialog_delete_bookmark_desc">Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada?</string>
|
||||
<string name="no_highlights_yet">Esiletõsteid veel pole.</string>
|
||||
<string name="unknown_chapter">Tundmatu peatükk</string>
|
||||
<string name="content_desc_options">Valikud</string>
|
||||
<string name="dialog_delete_highlight">Kas kustutada esiletõst?</string>
|
||||
<string name="dialog_delete_highlight_desc">Kas olete kindel, et soovite selle esiletõstmise jäädavalt kustutada?</string>
|
||||
<string name="dialog_delete_highlight_desc">Kas kustutada see esiletõst jäädavalt?</string>
|
||||
<string name="saved_image_message">Salvestatud %1$s</string>
|
||||
<string name="error_save_image">Pilti ei saanud salvestada.</string>
|
||||
<string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string>
|
||||
<string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string>
|
||||
<string name="toast_select_dictionary_first">Valige esmalt sõnastikurakendus.</string>
|
||||
<string name="toast_select_translate_first">Valige esmalt tõlkerakendus.</string>
|
||||
<string name="toast_select_search_first">Valige esmalt otsingurakendus.</string>
|
||||
<string name="toast_select_dictionary_first">Vali esmalt sõnastikurakendus.</string>
|
||||
<string name="toast_select_translate_first">Vali esmalt tõlkerakendus.</string>
|
||||
<string name="toast_select_search_first">Vali esmalt otsingurakendus.</string>
|
||||
<string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string>
|
||||
<string name="navigating_to_position">Navigeerimine asukohta…</string>
|
||||
<string name="dialog_permission_required">Nõutav luba</string>
|
||||
|
|
@ -578,7 +584,7 @@
|
|||
<string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string>
|
||||
<string name="action_i_understand">ma saan aru</string>
|
||||
<string name="navigating_to_chapter">Peatükki navigeerimine…</string>
|
||||
<string name="toast_select_offline_dict_first">Valige esmalt võrguühenduseta sõnastik.</string>
|
||||
<string name="toast_select_offline_dict_first">Vali esmalt võrguühenduseta sõnastik.</string>
|
||||
<string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string>
|
||||
<string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string>
|
||||
<string name="release_for_previous_chapter">Eelmise peatüki väljalase</string>
|
||||
|
|
@ -594,7 +600,7 @@
|
|||
<string name="action_reset">Lähtesta</string>
|
||||
<string name="label_size">Suurus</string>
|
||||
<string name="label_spacing">Vahekaugus</string>
|
||||
<string name="select_font">Valige Font</string>
|
||||
<string name="select_font">Vali Font</string>
|
||||
<string name="tab_presets">Eelseaded</string>
|
||||
<string name="tab_imported">Imporditud</string>
|
||||
<string name="button_import_from_files">Import failidest</string>
|
||||
|
|
@ -606,20 +612,20 @@
|
|||
<string name="visual_options_pdf_spread_two">Kaks lehte</string>
|
||||
<string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string>
|
||||
<string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string>
|
||||
<string name="visual_options_remove_page_gap">Eemaldage lehtede vahe</string>
|
||||
<string name="visual_options_remove_page_gap">Eemalda lehtede vahe</string>
|
||||
<string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
||||
<string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string>
|
||||
<string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string>
|
||||
<string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string>
|
||||
<string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string>
|
||||
<string name="visual_options_screen_orientation">Ekraani suund</string>
|
||||
<string name="visual_options_screen_orientation_desc">Valige, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
|
||||
<string name="visual_options_screen_orientation_desc">Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
|
||||
<string name="visual_options_progress_bar">Edenemisriba</string>
|
||||
<string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string>
|
||||
<string name="visual_options_progress_bar_position">positsioon</string>
|
||||
<string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string>
|
||||
<string name="visual_options_seamless_chapter_desc">Laadige lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
|
||||
<string name="visual_options_edge_padding">Eemaldage serva polsterdus</string>
|
||||
<string name="visual_options_seamless_chapter_desc">Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
|
||||
<string name="visual_options_edge_padding">Eemalda servapolsterdus</string>
|
||||
<string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string>
|
||||
<string name="reader_brightness_title">Heledus</string>
|
||||
<string name="reader_brightness_system">Kasutage süsteemi heledust</string>
|
||||
|
|
@ -636,10 +642,10 @@
|
|||
<string name="about_version_name">Versioon %1$s</string>
|
||||
<string name="about_build_code">Ehitamine %1$s</string>
|
||||
<string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string>
|
||||
<string name="about_privacy_desc">Kuidas me teie andmeid käsitleme.</string>
|
||||
<string name="about_privacy_desc">Kuidas me sinu andmeid käsitleme.</string>
|
||||
<string name="about_terms_desc">Kasutustingimused.</string>
|
||||
<string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string>
|
||||
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</string>
|
||||
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse.</string>
|
||||
<string name="banner_shelf_created">Loodi riiul "%1$s".</string>
|
||||
<string name="banner_smart_shelf_created">Loodi nutikas riiul "%1$s".</string>
|
||||
<string name="banner_shelf_renamed">Riiul nimetati ümber "%1$s".</string>
|
||||
|
|
@ -678,7 +684,7 @@
|
|||
<string name="tts_voice_adjustments">Hääle reguleerimine</string>
|
||||
<string name="tts_speed_label">Kiirus (%1$sx)</string>
|
||||
<string name="tts_pitch_label">Kõrgus (%1$sx)</string>
|
||||
<string name="tts_sample_text">Nii kõlavad teie praegused hääleseaded.</string>
|
||||
<string name="tts_sample_text">Nii kõlavad sinu praegused hääleseaded.</string>
|
||||
<string name="tts_pause_book">Peata raamat</string>
|
||||
<string name="tts_resume_book">Jätkamise raamat</string>
|
||||
<string name="tts_system_settings">Süsteemi hääle/mootori sätted</string>
|
||||
|
|
@ -688,7 +694,7 @@
|
|||
<string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string>
|
||||
<string name="action_scroll_to_top">Kerige üles</string>
|
||||
<string name="title_customize_toolbar">Kohandage tööriistariba</string>
|
||||
<string name="desc_customize_toolbar">Valige tööriistad, mida soovite nähtavana hoida. Tööriista märke tühistamine peidab selle kasutajaliidese eest, et anda teile tähelepanu kõrvalejuhtimiseta lugemisruumi.</string>
|
||||
<string name="desc_customize_toolbar">Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu.</string>
|
||||
<string name="tab_annotations">Märkused</string>
|
||||
<string name="filter_all">Kõik</string>
|
||||
<string name="filter_with_notes">Märkmetega</string>
|
||||
|
|
@ -706,8 +712,8 @@
|
|||
<string name="content_desc_undo">Võta tagasi</string>
|
||||
<string name="content_desc_redo">Tee uuesti</string>
|
||||
<string name="content_desc_show_dock">Näita dokki</string>
|
||||
<string name="content_desc_select_font_family">Valige Fontide perekond</string>
|
||||
<string name="content_desc_select_font_size">Valige Fondi suurus</string>
|
||||
<string name="content_desc_select_font_family">Vali fondipere</string>
|
||||
<string name="content_desc_select_font_size">Vali Fondi suurus</string>
|
||||
<string name="content_desc_font_background">Fondi taust</string>
|
||||
<string name="content_desc_bold">Paks</string>
|
||||
<string name="content_desc_italic">Kursiiv</string>
|
||||
|
|
@ -731,7 +737,7 @@
|
|||
<string name="menu_insert_blank_page">Sisesta tühi leht</string>
|
||||
<string name="menu_delete_page">Kustuta leht</string>
|
||||
<string name="generating_reflow_progress">Tekib… %1$d%%</string>
|
||||
<string name="action_open_text_view">Avage tekstivaade</string>
|
||||
<string name="action_open_text_view">Ava tekstivaade</string>
|
||||
<string name="action_generate_text_view">Loo tekstivaade</string>
|
||||
<string name="action_share">Jaga</string>
|
||||
<string name="action_save_copy_to_device">Salvesta koopia seadmesse</string>
|
||||
|
|
@ -743,11 +749,11 @@
|
|||
<string name="msg_search_pages_count">%1$d+ Lehekülgi</string>
|
||||
<string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string>
|
||||
<string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string>
|
||||
<string name="title_select_ocr_language">Valige OCR Keel</string>
|
||||
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks valige selle dokumendi esmane keel/skript.</string>
|
||||
<string name="title_select_ocr_language">Vali OCR Keel</string>
|
||||
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri.</string>
|
||||
<string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid > OCR Keel.</string>
|
||||
<string name="title_reindex_document">Kas dokument uuesti indekseerida?</string>
|
||||
<string name="desc_reindex_document_warning">You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background.</string>
|
||||
<string name="desc_reindex_document_warning">Muudad OCR-i kirja väärtuseks %1$s.\n\nOtsingu täpsuse tagamiseks peame olemasoleva indeksi tühjendama ja OCR-i vajavad lehed selle uue keelega uuesti skannima.\n\nSee toimub taustal.</string>
|
||||
<string name="action_reindex">Indekseeri uuesti</string>
|
||||
<string name="title_password_protected">Parooliga kaitstud</string>
|
||||
<string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string>
|
||||
|
|
@ -758,13 +764,13 @@
|
|||
<string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string>
|
||||
<string name="action_visit">Külastage</string>
|
||||
<string name="title_save_to_device">Salvesta seadmesse</string>
|
||||
<string name="desc_choose_format_save">Valige salvestamiseks vorming:</string>
|
||||
<string name="desc_choose_format_save">Vali salvestamiseks vorming:</string>
|
||||
<string name="action_with_annotations">Koos märkustega</string>
|
||||
<string name="action_original">Originaal</string>
|
||||
<string name="desc_choose_format_share">Valige jagamiseks vorming:</string>
|
||||
<string name="desc_choose_format_share">Vali jagamiseks vorming:</string>
|
||||
<string name="msg_preparing_pdf">Ettevalmistus PDF…</string>
|
||||
<string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string>
|
||||
<string name="msg_no_other_pdfs_found">Teisi PDF-e teie teegist ei leitud.</string>
|
||||
<string name="msg_no_other_pdfs_found">Teisi PDF-e sinu teegist ei leitud.</string>
|
||||
<string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string>
|
||||
<string name="msg_page_added_at">Leht lisatud aadressil %1$d</string>
|
||||
<string name="msg_page_deleted">Leht kustutatud</string>
|
||||
|
|
@ -792,23 +798,24 @@
|
|||
<string name="dialog_strict_file_filter_title">Luba range failifilter</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>
|
||||
<string name="language_system_default">Süsteemi vaikeseade</string>
|
||||
<string name="language_english">inglise keel</string>
|
||||
<string name="language_english_default">inglise keel (vaikimisi)</string>
|
||||
<string name="language_english">English (inglise)</string>
|
||||
<string name="language_english_default">English (vaikimisi)</string>
|
||||
<string name="language_arabic">العربية (araabia)</string>
|
||||
<string name="language_german">saksa (saksa)</string>
|
||||
<string name="language_german">Deutsch (saksa)</string>
|
||||
<string name="language_turkish">türkçe (türgi)</string>
|
||||
<string name="language_french">Français (prantsuse)</string>
|
||||
<string name="language_russian">Русский (vene)</string>
|
||||
<string name="language_belarusian">Беларуская (valgevene keel)</string>
|
||||
<string name="language_spanish">español (hispaania)</string>
|
||||
<string name="language_portuguese_brazilian">portugali keel (Brasiilia)</string>
|
||||
<string name="language_italian">itaalia keel (itaalia)</string>
|
||||
<string name="language_portuguese_brazilian">Português (Brasiilia)</string>
|
||||
<string name="language_italian">Italiano (itaalia)</string>
|
||||
<string name="language_polish">polski (poola)</string>
|
||||
<string name="language_vietnamese">Tiếng Việt (vietnami)</string>
|
||||
<string name="language_japanese">日本語 (jaapani keel)</string>
|
||||
<string name="language_korean">한국어 (korea)</string>
|
||||
<string name="language_hindi">हिन्दी (hindi)</string>
|
||||
<string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string>
|
||||
<string name="language_estonian">Eesti</string>
|
||||
<string name="app_theme_title">Rakenduse teema</string>
|
||||
<string name="app_theme_appearance">Välimus</string>
|
||||
<string name="app_theme_contrast">Kontrast</string>
|
||||
|
|
@ -828,7 +835,7 @@
|
|||
<string name="content_desc_app_theme">Rakenduse teema</string>
|
||||
<string name="content_desc_app_icon">Rakenduse ikoon</string>
|
||||
<string name="content_desc_device">Seade</string>
|
||||
<string name="content_desc_open_drawer">Avage sahtel</string>
|
||||
<string name="content_desc_open_drawer">Ava sahtel</string>
|
||||
<string name="content_desc_profile_picture">Profiilipilt</string>
|
||||
<string name="content_desc_profile">Profiil</string>
|
||||
<string name="content_desc_pro_feature">Pro funktsioon</string>
|
||||
|
|
@ -874,7 +881,7 @@
|
|||
<string name="tts_tab_cloud_voices">Pilve hääled</string>
|
||||
<string name="tts_tab_device_voices">Seadme hääled</string>
|
||||
<string name="tts_tab_cloud_cache">Pilve vahemälu</string>
|
||||
<string name="tts_select_cloud_voice">Valige Kvaliteetne pilvehääl</string>
|
||||
<string name="tts_select_cloud_voice">Vali Kvaliteetne pilvehääl</string>
|
||||
<string name="tts_clear_samples">Puhasta proovid</string>
|
||||
<string name="tts_system_default_voice">Süsteemi vaikehääl</string>
|
||||
<string name="tts_uses_device_settings">Kasutab seadme sätteid</string>
|
||||
|
|
@ -913,11 +920,11 @@
|
|||
<string name="legal_by_purchasing">Ostes,</string>
|
||||
<string name="dialog_out_of_credits_title">Krediidid otsas</string>
|
||||
<string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string>
|
||||
<string name="action_get_pro_or_add_credits">Hankige Pro / lisage krediiti</string>
|
||||
<string name="dialog_unlock_page_summarization">Avage lehe kokkuvõte</string>
|
||||
<string name="dialog_unlock_page_summarization_desc">Hankige täpseid kokkuvõtteid mis tahes lehekülje kohta, millel on Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
|
||||
<string name="dialog_download_bubble_zoom_model">Laadige alla Bubble Zoom mudel</string>
|
||||
<string name="dialog_download_bubble_zoom_model_desc">Funktsiooni Bubble Zoom kasutamiseks kasutage AI mudel tuleb alla laadida (~134 MB). Kas soovite selle kohe alla laadida?</string>
|
||||
<string name="action_get_pro_or_add_credits">Hangi Pro / lisa krediiti</string>
|
||||
<string name="dialog_unlock_page_summarization">Ava lehe kokkuvõte</string>
|
||||
<string name="dialog_unlock_page_summarization_desc">Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada.</string>
|
||||
<string name="dialog_download_bubble_zoom_model">Laadi alla Bubble Zoom mudel</string>
|
||||
<string name="dialog_download_bubble_zoom_model_desc">Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla?</string>
|
||||
<string name="action_translate">Tõlgi</string>
|
||||
<string name="pdf_back_to_page_short">Tagasi lk %1$d</string>
|
||||
<string name="pdf_page_short">Lehekülg %1$d</string>
|
||||
|
|
@ -939,7 +946,7 @@
|
|||
<string name="content_desc_reset_zoom">Lähtestage suum</string>
|
||||
<string name="content_desc_generate_demo_annotations">Loo demomärkusi</string>
|
||||
<string name="tooltip_demo_annotations">Demo annotatsioonid</string>
|
||||
<string name="content_desc_open_pen_playground">Avage pliiatsi mänguväljak</string>
|
||||
<string name="content_desc_open_pen_playground">Ava pliiatsi mänguväljak</string>
|
||||
<string name="content_desc_new_tab">Uus vaheleht</string>
|
||||
<string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string>
|
||||
<string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string>
|
||||
|
|
@ -949,7 +956,7 @@
|
|||
<string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string>
|
||||
<string name="ocr_language_chinese">hiina + inglise keel</string>
|
||||
<string name="ocr_language_japanese">jaapani + inglise keel</string>
|
||||
<string name="ocr_language_korean">Korea + inglise keel</string>
|
||||
<string name="ocr_language_korean">korea + inglise keel</string>
|
||||
<string name="msg_page_unavailable">Leht pole saadaval</string>
|
||||
<string name="default_document_title">Dokument</string>
|
||||
<string name="generated_author">Loodud</string>
|
||||
|
|
@ -968,7 +975,7 @@
|
|||
<string name="content_desc_play_pause">Esita/Paus</string>
|
||||
<string name="content_desc_reset_speed">Lähtestage kiirus</string>
|
||||
<string name="content_desc_reset_pitch">Lähtesta helikõrgus</string>
|
||||
<string name="dialog_select_color">Valige Värv</string>
|
||||
<string name="dialog_select_color">Vali Värv</string>
|
||||
<string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string>
|
||||
<string name="clip_label_copied_link">Kopeeritud link</string>
|
||||
<string name="clip_label_copied_text">Kopeeritud tekst</string>
|
||||
|
|
@ -1000,8 +1007,8 @@
|
|||
<string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string>
|
||||
<string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string>
|
||||
<string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string>
|
||||
<string name="ai_error_choose_model">Valige mudel %1$s aastal AI võtme ja mudeli sätted.</string>
|
||||
<string name="ai_error_add_provider_key">Lisage a %1$s API sisestage AI võtme ja mudeli sätted.</string>
|
||||
<string name="ai_error_choose_model">Vali mudel %1$s AI võtmete ja mudelite sätetes.</string>
|
||||
<string name="ai_error_add_provider_key">Lisa %1$s API-võti AI võtmete ja mudelite sätetes.</string>
|
||||
<string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string>
|
||||
<string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string>
|
||||
<string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string>
|
||||
|
|
@ -1045,7 +1052,7 @@
|
|||
<string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string>
|
||||
<string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string>
|
||||
<string name="dialog_save_provider_key">Salvesta %1$s võti?</string>
|
||||
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja 3 viimast tähemärki. Kui soovite seda hiljem muuta, asendage see või kustutage see.</string>
|
||||
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti.</string>
|
||||
<string name="dialog_delete_provider_key">Kustuta %1$s võti?</string>
|
||||
<string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string>
|
||||
<string name="ai_settings_no_key_saved">Võti pole salvestatud</string>
|
||||
|
|
@ -1058,7 +1065,7 @@
|
|||
<string name="theme_textured">Tekstuuriga</string>
|
||||
<string name="theme_custom_solid_default">Kohandatud tahke</string>
|
||||
<string name="theme_custom_textured_default">Kohandatud tekstuuriga</string>
|
||||
<string name="theme_select_custom_texture">Valige Kohandatud tekstuur</string>
|
||||
<string name="theme_select_custom_texture">Vali Kohandatud tekstuur</string>
|
||||
<string name="app_theme_text_brightness_light">Teksti heledus (hele)</string>
|
||||
<string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string>
|
||||
<string name="label_default">Vaikimisi</string>
|
||||
|
|
@ -1094,7 +1101,7 @@
|
|||
<string name="toolbar_hidden_tools">Peidetud tööriistad</string>
|
||||
<string name="toolbar_more_menu">Rohkem menüüd</string>
|
||||
<string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string>
|
||||
<string name="toolbar_drop_tools_here">Pange tööriistad siia</string>
|
||||
<string name="toolbar_drop_tools_here">Pane tööriistad siia</string>
|
||||
<string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string>
|
||||
<string name="tool_external_apps">Välised rakendused</string>
|
||||
<string name="tool_navigation_slider">Navigeerimisliugur</string>
|
||||
|
|
@ -1145,7 +1152,7 @@
|
|||
<string name="book_replacements_empty_replacement">tühi tekst</string>
|
||||
<string name="language_dutch">Holland (hollandi)</string>
|
||||
<string name="language_ukrainian">Українська (ukraina)</string>
|
||||
<string name="language_indonesian">indoneesia (indoneesia)</string>
|
||||
<string name="language_indonesian">Bahasa Indonesia (indoneesia)</string>
|
||||
<string name="desktop_about">Umbes</string>
|
||||
<string name="desktop_about_subtitle">Lauaarvuti lugeja</string>
|
||||
<string name="desktop_access">Juurdepääs töölauale</string>
|
||||
|
|
@ -1159,10 +1166,10 @@
|
|||
<string name="desktop_cache_format">Vahemälu: %1$s</string>
|
||||
<string name="desktop_cached">Vahemällu salvestatud</string>
|
||||
<string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string>
|
||||
<string name="desktop_choose_cloud_tts_voice">Valige Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
|
||||
<string name="desktop_choose_cloud_tts_voice">Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
|
||||
<string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string>
|
||||
<string name="desktop_clear_voice_cache">Tühjendage hääle vahemälu</string>
|
||||
<string name="desktop_close_tools">Sulgege tööriistad</string>
|
||||
<string name="desktop_clear_voice_cache">Tühjenda hääle vahemälu</string>
|
||||
<string name="desktop_close_tools">Sulge tööriistad</string>
|
||||
<string name="desktop_cloud_sync">Pilvesünkroonimine</string>
|
||||
<string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string>
|
||||
<string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string>
|
||||
|
|
@ -1179,12 +1186,12 @@
|
|||
<string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string>
|
||||
<string name="desktop_delete_font">Kustuta font</string>
|
||||
<string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string>
|
||||
<string name="desktop_delete_shelf_desc">Kas kustutada \"%1$s\"? Raamatud jäävad teie raamatukogusse.</string>
|
||||
<string name="desktop_delete_shelf_desc">Kas kustutada \"%1$s\"? Raamatud jäävad sinu raamatukogusse.</string>
|
||||
<string name="desktop_delete_summary">Kustuta kokkuvõte</string>
|
||||
<string name="desktop_disabled">Keelatud</string>
|
||||
<string name="desktop_drop_files_to_import">Pukseerige failid importimiseks</string>
|
||||
<string name="desktop_drop_supported_files_to_import">Eemaldage importimiseks toetatud failid</string>
|
||||
<string name="desktop_email_support_desc">Kui soovite midagi muud, võtke meiega otse e-posti teel ühendust.</string>
|
||||
<string name="desktop_drop_files_to_import">Pukseeri failid importimiseks</string>
|
||||
<string name="desktop_drop_supported_files_to_import">Eemalda importimiseks toetatud failid</string>
|
||||
<string name="desktop_email_support_desc">Kui vajad midagi muud, võta meiega otse e-posti teel ühendust.</string>
|
||||
<string name="desktop_equals">Võrdub</string>
|
||||
<string name="desktop_extras">Lisad</string>
|
||||
<string name="desktop_feedback">Tagasiside</string>
|
||||
|
|
@ -1195,36 +1202,36 @@
|
|||
<string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string>
|
||||
<string name="desktop_generate_recap">Loo kokkuvõte</string>
|
||||
<string name="desktop_generate_summary">Loo kokkuvõte</string>
|
||||
<string name="desktop_get_in_touch_desc">Teatage vigadest, taotlege funktsioone või võtke otse ühendust toega.</string>
|
||||
<string name="desktop_get_in_touch_desc">Teata vigadest, taotle funktsioone või võta toega otse ühendust.</string>
|
||||
<string name="desktop_github_sponsors">GitHubi sponsorid</string>
|
||||
<string name="desktop_github_sponsors_desc">Toetage arengut GitHubi sponsorite kaudu.</string>
|
||||
<string name="desktop_github_sponsors_desc">Toeta arendust GitHub Sponsorsi kaudu.</string>
|
||||
<string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string>
|
||||
<string name="desktop_greater_than">Suurem kui</string>
|
||||
<string name="desktop_help">Abi</string>
|
||||
<string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string>
|
||||
<string name="desktop_hide">Peida</string>
|
||||
<string name="desktop_import_files">Importige faile</string>
|
||||
<string name="desktop_import_files">Impordi faile</string>
|
||||
<string name="desktop_issues">Probleemid</string>
|
||||
<string name="desktop_issues_desc">Avage probleemide jälgija vigade ja funktsioonitaotluste jaoks.</string>
|
||||
<string name="desktop_issues_desc">Ava probleemide jälgija vigade ja funktsioonisoovide jaoks.</string>
|
||||
<string name="desktop_less_than">Vähem kui</string>
|
||||
<string name="desktop_library_and_reader">Raamatukogu ja lugeja</string>
|
||||
<string name="desktop_match_any">Ükskõik milline</string>
|
||||
<string name="desktop_more_library_actions">Raamatukogu tegevused</string>
|
||||
<string name="desktop_more_menu">Rohkem</string>
|
||||
<string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string>
|
||||
<string name="desktop_no_custom_fonts_desc">Importige TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
|
||||
<string name="desktop_no_custom_fonts_desc">Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
|
||||
<string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \"%1$s\"</string>
|
||||
<string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string>
|
||||
<string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string>
|
||||
<string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string>
|
||||
<string name="desktop_open_readers">Avatud lugejad</string>
|
||||
<string name="desktop_opening_title">Avamine %1$s</string>
|
||||
<string name="desktop_opening_your_library">Teie raamatukogu avamine</string>
|
||||
<string name="desktop_opening_your_library">Raamatukogu avamine</string>
|
||||
<string name="desktop_operator">Operaator</string>
|
||||
<string name="desktop_page">Lehekülg</string>
|
||||
<string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string>
|
||||
<string name="desktop_patreon">Patreon</string>
|
||||
<string name="desktop_patreon_desc">Toetage projekti Patreonis.</string>
|
||||
<string name="desktop_patreon_desc">Toeta projekti Patreonis.</string>
|
||||
<string name="desktop_paused">Peatatud</string>
|
||||
<string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string>
|
||||
<string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string>
|
||||
|
|
@ -1269,13 +1276,13 @@
|
|||
<string name="desktop_view">Vaade</string>
|
||||
<string name="desktop_voice_cache">Hääle vahemälu</string>
|
||||
<string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string>
|
||||
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string>
|
||||
<string name="desktop_webview_preparing_progress">Pakitud manustatud veebivaate ettevalmistamine %1$d%%</string>
|
||||
<string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string>
|
||||
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string>
|
||||
<string name="desktop_webview_start_error">Manustatud veebivaadet ei saanud käivitada: %1$s</string>
|
||||
<string name="desktop_working">Töötab…</string>
|
||||
<string name="desktop_workspace">Tööruum</string>
|
||||
<string name="desktop_add_to_shelf">Lisa riiulile</string>
|
||||
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string>
|
||||
<string name="desktop_create_shelf_first">Loo esmalt riiul ja lisa siis valitud raamatud sinna.</string>
|
||||
<string name="desktop_create_theme">Loo teema</string>
|
||||
<string name="desktop_existing_tags_format">Olemasolev: %1$s</string>
|
||||
<string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string>
|
||||
|
|
@ -1291,20 +1298,20 @@
|
|||
<string name="desktop_annotation_options">Märkuste valikud</string>
|
||||
<string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string>
|
||||
<string name="desktop_assist">Abi</string>
|
||||
<string name="desktop_choose_pdf_to_save">Valige, milline PDF päästa.</string>
|
||||
<string name="desktop_choose_pdf_to_save">Vali, milline PDF päästa.</string>
|
||||
<string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string>
|
||||
<string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string>
|
||||
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisage a Gemini klahvi ja valige Gemini pilv TTS aastal AI võtmed ja mudelid.</string>
|
||||
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid.</string>
|
||||
<string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string>
|
||||
<string name="desktop_cloud_tts_sign_in_required_desc">Logige sisse rakendusega Google to use cloud TTS.</string>
|
||||
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilv TTS needs a signed-in account with credits. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
|
||||
<string name="desktop_cloud_tts_sign_in_required_desc">Pilve-TTS-i kasutamiseks logi Google’iga sisse.</string>
|
||||
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||
<string name="desktop_color">Värv</string>
|
||||
<string name="desktop_comment_options">Kommentaaride valikud</string>
|
||||
<string name="desktop_custom_theme_default">Kohandatud</string>
|
||||
<string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string>
|
||||
<string name="desktop_delete_annotation_title">Kas kustutada märkus?</string>
|
||||
<string name="desktop_document_text">Dokumendi tekst</string>
|
||||
<string name="desktop_embedded_pdf_comment">Manustatud PDF kommenteerida</string>
|
||||
<string name="desktop_embedded_pdf_comment">Manustatud PDF-i kommentaar</string>
|
||||
<string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string>
|
||||
<string name="desktop_feature_unavailable">Funktsioon pole saadaval</string>
|
||||
<string name="desktop_finished">Valmis</string>
|
||||
|
|
@ -1330,14 +1337,14 @@
|
|||
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
|
||||
<string name="desktop_open_comment">Ava kommentaar</string>
|
||||
<string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string>
|
||||
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve kasutamine TTS needs credits on desktop. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
|
||||
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
||||
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
||||
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
|
||||
<string name="desktop_pan">Pan</string>
|
||||
<string name="desktop_pdf_action_failed">PDF tegevus ebaõnnestus</string>
|
||||
<string name="desktop_pdf_action_failed_desc">PDF action could not be completed.</string>
|
||||
<string name="desktop_pdf_comment">PDF kommenteerida</string>
|
||||
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||
<string name="desktop_out_of_credits_generic_feature_desc">See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||
<string name="desktop_out_of_credits_recaps_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||
<string name="desktop_out_of_credits_summaries_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
|
||||
<string name="desktop_pan">Panoraami</string>
|
||||
<string name="desktop_pdf_action_failed">PDF-i toiming ebaõnnestus</string>
|
||||
<string name="desktop_pdf_action_failed_desc">PDF-i toimingut ei saanud lõpule viia.</string>
|
||||
<string name="desktop_pdf_comment">PDF-i kommentaar</string>
|
||||
<string name="desktop_pdf_compact_page_number">lk. %1$d</string>
|
||||
<string name="desktop_pdf_page_content_desc">PDF leht %1$d</string>
|
||||
<string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string>
|
||||
|
|
@ -1359,15 +1366,15 @@
|
|||
<string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
|
||||
<string name="desktop_round_highlighter">Ümmargune highlighter</string>
|
||||
<string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string>
|
||||
<string name="desktop_scroll">Kerige</string>
|
||||
<string name="desktop_scroll">Keri</string>
|
||||
<string name="desktop_search_in_pdf">Otsi: PDF</string>
|
||||
<string name="desktop_select_text">Valige tekst</string>
|
||||
<string name="desktop_select_text">Vali tekst</string>
|
||||
<string name="desktop_selected_annotation_format">Valitud %1$s</string>
|
||||
<string name="desktop_show_search_results">Kuva otsingutulemused</string>
|
||||
<string name="desktop_sign_in_required_generic_feature_desc">Logige sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
|
||||
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logige sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
|
||||
<string name="desktop_sign_in_required_recaps_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||
<string name="desktop_sign_in_required_summaries_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||
<string name="desktop_sign_in_required_generic_feature_desc">Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
|
||||
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
|
||||
<string name="desktop_sign_in_required_recaps_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||
<string name="desktop_sign_in_required_summaries_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
|
||||
<string name="desktop_stopped">Peatatud</string>
|
||||
<string name="desktop_text_note">Tekstimärkus</string>
|
||||
<string name="desktop_text_note_lowercase">tekstimärkus</string>
|
||||
|
|
@ -1394,13 +1401,13 @@
|
|||
<string name="desktop_book_badge_folder">Kaust</string>
|
||||
<string name="desktop_browse">Sirvige</string>
|
||||
<string name="desktop_categories">Kategooriad</string>
|
||||
<string name="desktop_chapter_short_format">Ch. %1$d</string>
|
||||
<string name="desktop_chapter_turns">Peatükk Pöörded</string>
|
||||
<string name="desktop_choose_font">Valige font</string>
|
||||
<string name="desktop_choose_reader_texture">Valige lugeja tekstuur</string>
|
||||
<string name="desktop_chapter_short_format">Ptk %1$d</string>
|
||||
<string name="desktop_chapter_turns">Peatüki pöörded</string>
|
||||
<string name="desktop_choose_font">Vali font</string>
|
||||
<string name="desktop_choose_reader_texture">Vali lugeja tekstuur</string>
|
||||
<string name="desktop_clear_file_types">Kustuta failitüübid</string>
|
||||
<string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string>
|
||||
<string name="desktop_clear_sources">Selged allikad</string>
|
||||
<string name="desktop_clear_sources">Tühjenda allikad</string>
|
||||
<string name="desktop_clear_status">Tühjenda olek</string>
|
||||
<string name="desktop_clear_tags">Tühjenda sildid</string>
|
||||
<string name="desktop_close_reader">Sule lugeja</string>
|
||||
|
|
@ -1427,16 +1434,16 @@
|
|||
<string name="desktop_label_pair_format">%1$s - %2$s</string>
|
||||
<string name="desktop_hide_filters">Peida filtrid</string>
|
||||
<string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string>
|
||||
<string name="desktop_highlight_palette_hint">Puudutage pesa ja seejärel valige värv.</string>
|
||||
<string name="desktop_home_subtitle">Jätkake lugemist ja hiljutisi raamatuid</string>
|
||||
<string name="desktop_import_books">Importige raamatuid</string>
|
||||
<string name="desktop_highlight_palette_hint">Puuduta pesa ja vali värv.</string>
|
||||
<string name="desktop_home_subtitle">Jätka lugemist ja vaata hiljutisi raamatuid</string>
|
||||
<string name="desktop_import_books">Impordi raamatuid</string>
|
||||
<string name="desktop_import_folder">Impordi kaust</string>
|
||||
<string name="desktop_imported_fonts">Imporditud fondid</string>
|
||||
<string name="desktop_import_result_pair">%1$s %2$s</string>
|
||||
<string name="desktop_increase_format">Suurendada %1$s</string>
|
||||
<string name="desktop_jump_history">Hüppe ajalugu</string>
|
||||
<string name="desktop_layout_spacing">Paigutus ja vahekaugus</string>
|
||||
<string name="desktop_library_empty_desc">Importige failid rakenduste salvestusruumi või lisage failide lugemiseks kaust.</string>
|
||||
<string name="desktop_library_empty_desc">Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust.</string>
|
||||
<string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string>
|
||||
<string name="desktop_ai_keys">AI võtmed</string>
|
||||
<string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string>
|
||||
|
|
|
|||
|
|
@ -1512,4 +1512,6 @@
|
|||
<string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string>
|
||||
<string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string>
|
||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||
<string name="error_copy_to_clipboard">Không thể sao chép vào khay nhớ tạm</string>
|
||||
<string name="error_print_password_protected">Không thể in tệp PDF được bảo vệ bằng mật khẩu</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -201,8 +201,13 @@
|
|||
<string name="external_file_keep">Keep in Library</string>
|
||||
<string name="external_file_delete">Remove</string>
|
||||
<string name="external_file_behavior_ask">Ask Every Time</string>
|
||||
<string name="external_file_behavior_ask_desc">After closing an externally opened file, ask whether to keep it in the library or remove it.</string>
|
||||
<string name="external_file_behavior_keep">Always Keep</string>
|
||||
<string name="external_file_behavior_keep_desc">Externally opened files are copied into the library and kept after closing.</string>
|
||||
<string name="external_file_behavior_delete">Always Remove</string>
|
||||
<string name="external_file_behavior_delete_desc">Externally opened files are copied for reading, then removed after closing.</string>
|
||||
<string name="external_file_behavior_temporary">Open Temporarily</string>
|
||||
<string name="external_file_behavior_temporary_desc">Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library.</string>
|
||||
<string name="options_external_file_behavior">External File Behavior</string>
|
||||
|
||||
<!-- OPDS — "OPDS" is a technical protocol name, do not translate it. -->
|
||||
|
|
@ -480,10 +485,14 @@
|
|||
<string name="banner_saving_original_pdf">Saving original PDF…</string>
|
||||
<!-- PDF = file format name — do not translate. -->
|
||||
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
|
||||
<string name="banner_saving_original_file">Saving original file...</string>
|
||||
<string name="banner_original_file_saved">Original file saved successfully.</string>
|
||||
<string name="error_saving_file">Error saving file: %1$s</string>
|
||||
<!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". -->
|
||||
<string name="share_subject">Sharing: %1$s</string>
|
||||
<!-- PDF = file format name — do not translate. -->
|
||||
<string name="share_chooser_title">Share PDF</string>
|
||||
<string name="share_file_chooser_title">Share file</string>
|
||||
<!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". -->
|
||||
<string name="error_share_failed">Share failed: %1$s</string>
|
||||
<!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." -->
|
||||
|
|
@ -1054,6 +1063,8 @@
|
|||
|
||||
<!-- PdfViewerScreen & General Reader -->
|
||||
<string name="error_open_print_settings">Could not open print settings</string>
|
||||
<string name="error_copy_to_clipboard">Could not copy to clipboard</string>
|
||||
<string name="error_print_password_protected">Password protected PDF files cannot be printed</string>
|
||||
<!-- PDF = file format name — do not translate. -->
|
||||
<string name="loading_pdf">Loading PDF…</string>
|
||||
<!-- PDF = file format name — do not translate. -->
|
||||
|
|
@ -1194,6 +1205,7 @@
|
|||
<string name="language_korean">한국어 (Korean)</string>
|
||||
<string name="language_hindi">हिन्दी (Hindi)</string>
|
||||
<string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string>
|
||||
<string name="language_estonian">Eesti (Estonian)</string>
|
||||
|
||||
<!-- App-wide theme controls in HomeScreen.kt. -->
|
||||
<string name="app_theme_title">App Theme</string>
|
||||
|
|
|
|||
|
|
@ -19,4 +19,5 @@
|
|||
<locale android:name="ko"/>
|
||||
<locale android:name="hi"/>
|
||||
<locale android:name="zh-CN"/>
|
||||
<locale android:name="et"/>
|
||||
</locale-config>
|
||||
|
|
|
|||
|
|
@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
|
|||
|
||||
@Test
|
||||
fun `vietnamese strings cover translatable base resources`() {
|
||||
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estonian strings cover translatable base resources`() {
|
||||
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
|
||||
}
|
||||
|
||||
private fun assertLocaleCoversTranslatableBaseResources(
|
||||
localeDirectory: String,
|
||||
localeName: String
|
||||
) {
|
||||
val resDirectory = findResDirectory()
|
||||
val baseNames = readResourceNames(
|
||||
stringsFile = File(resDirectory, "values/strings.xml"),
|
||||
includeNonTranslatable = false
|
||||
)
|
||||
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml"))
|
||||
val missingNames = baseNames.filterNot { it in vietnameseNames }
|
||||
val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
|
||||
val missingNames = baseNames.filterNot { it in localizedNames }
|
||||
|
||||
assertTrue(
|
||||
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||
"Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||
missingNames.isEmpty()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
|
|||
assertEquals(
|
||||
listOf(
|
||||
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
|
||||
"id", "vi", "ja", "ko", "hi", "zh-CN"
|
||||
"id", "vi", "ja", "ko", "hi", "zh-CN", "et"
|
||||
),
|
||||
supportedAppLanguageOptions.mapNotNull { it.tag }
|
||||
)
|
||||
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes)
|
||||
assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
|
|||
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
|
||||
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
|
||||
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
|
||||
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
|
||||
|
||||
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
|
||||
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
|
||||
|
|
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
|
|||
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
|
||||
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
|
||||
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
|
||||
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ClipboardUtilsTest {
|
||||
@Test
|
||||
fun `set primary clip reports success`() {
|
||||
assertTrue(setPrimaryClipSafely {})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set primary clip handles security rejection`() {
|
||||
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalFileOpenRouteDeciderTest {
|
||||
@Test
|
||||
fun `temporary behavior routes to temporary activity`() {
|
||||
assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY"))
|
||||
assertEquals(
|
||||
TemporaryExternalFileActivity::class.java,
|
||||
ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing behaviors route to main activity`() {
|
||||
listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior ->
|
||||
assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior))
|
||||
assertEquals(
|
||||
MainActivity::class.java,
|
||||
ExternalFileOpenRouteDecider.targetActivityClass(behavior)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
|
|
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
|
|||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -45,6 +47,7 @@ class MainViewModelTest {
|
|||
private lateinit var mockApplication: Application
|
||||
private lateinit var mockPrefs: SharedPreferences
|
||||
private lateinit var mockEditor: SharedPreferences.Editor
|
||||
private val prefsStringSets = mutableMapOf<String, Set<String>>()
|
||||
|
||||
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
||||
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
|
||||
|
|
@ -64,6 +67,12 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
private class TestMainViewModel(application: Application) : MainViewModel(application) {
|
||||
val locallyCleanedBookIds = mutableListOf<String>()
|
||||
|
||||
override suspend fun cleanupBookDataLocally(bookId: String) {
|
||||
locallyCleanedBookIds += bookId
|
||||
}
|
||||
|
||||
fun clearForTest() {
|
||||
ViewModel::class.java
|
||||
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
|
||||
|
|
@ -83,6 +92,7 @@ class MainViewModelTest {
|
|||
billingStateFlow.value = ProUpgradeState()
|
||||
customFontsFlow.value = emptyList()
|
||||
ttsStateFlow.value = TtsPlaybackManager.TtsState()
|
||||
prefsStringSets.clear()
|
||||
|
||||
mockkStatic(Log::class)
|
||||
every { Log.isLoggable(any(), any()) } returns false
|
||||
|
|
@ -109,9 +119,14 @@ class MainViewModelTest {
|
|||
every { mockApplication.filesDir } returns filesDir
|
||||
every { mockApplication.cacheDir } returns cacheDir
|
||||
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
|
||||
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
|
||||
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
|
||||
every { mockPrefs.edit() } returns mockEditor
|
||||
|
||||
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
|
||||
every { mockPrefs.getStringSet(any(), any()) } answers {
|
||||
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
|
||||
}
|
||||
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
|
||||
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
|
||||
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
||||
|
|
@ -174,6 +189,7 @@ class MainViewModelTest {
|
|||
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
|
||||
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
|
||||
|
||||
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
||||
|
|
@ -417,38 +433,39 @@ class MainViewModelTest {
|
|||
viewModel.setStrictFileFilter(true)
|
||||
viewModel.setUsePdfFileNameAsDisplayName(true)
|
||||
viewModel.setExternalFileBehavior("KEEP")
|
||||
viewModel.setExternalFileBehavior("TEMPORARY")
|
||||
|
||||
val state = viewModel.uiState.first {
|
||||
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
|
||||
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
|
||||
}
|
||||
assertTrue(state.useStrictFileFilter)
|
||||
assertTrue(state.usePdfFileNameAsDisplayName)
|
||||
assertEquals("KEEP", state.externalFileBehavior)
|
||||
assertEquals("TEMPORARY", state.externalFileBehavior)
|
||||
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
|
||||
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
|
||||
verify { mockEditor.putString("external_file_behavior", "KEEP") }
|
||||
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
|
||||
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
|
||||
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
|
||||
every {
|
||||
mockPrefs.getStringSet("pending_external_file_removals", any())
|
||||
} returns mutableSetOf(pendingEntry)
|
||||
prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
|
||||
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
|
||||
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
|
||||
|
||||
val restored = TestMainViewModel(mockApplication)
|
||||
try {
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
coVerify(timeout = 1_000) {
|
||||
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
|
||||
}
|
||||
|
||||
coVerify {
|
||||
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
|
||||
}
|
||||
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
|
||||
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
|
||||
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
|
||||
verify { mockEditor.remove("pending_external_file_removals") }
|
||||
|
|
@ -457,6 +474,60 @@ class MainViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
|
||||
val resolver = mockk<ContentResolver>()
|
||||
every { mockApplication.contentResolver } returns resolver
|
||||
every { resolver.getType(externalUri) } returns "application/pdf"
|
||||
every { resolver.query(externalUri, null, null, null, null) } returns null
|
||||
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
|
||||
|
||||
viewModel.onFileSelected(
|
||||
externalUri,
|
||||
isFromRecent = false,
|
||||
isExternalIntent = true,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
|
||||
assertEquals(externalUri, selected.selectedPdfUri)
|
||||
assertEquals(null, selected.showExternalFileSavePromptFor)
|
||||
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
|
||||
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
|
||||
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val item = recentFile("external-book", type = FileType.PDF)
|
||||
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
|
||||
viewModel.trackExternalOpenForClose(
|
||||
bookId = item.bookId,
|
||||
importedCopyUriString = null,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
viewModel.onRecentFileClicked(item)
|
||||
advanceUntilIdle()
|
||||
viewModel.uiState.first { it.selectedBookId == item.bookId }
|
||||
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
|
||||
|
||||
viewModel.clearSelectedFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
|
||||
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
|
||||
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
|
||||
assertTrue(finishEvent.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
|
|
|
|||
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReaderPopupSizingTest {
|
||||
|
||||
@Test
|
||||
fun `modal max height leaves edge margin on landscape-height screens`() {
|
||||
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modal max height uses preferred minimum when there is room`() {
|
||||
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modal max height stays within tiny screens`() {
|
||||
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImportedFontFileNameTest {
|
||||
@Test
|
||||
fun importedFontFileNamePreservesVariableFontVariantTokens() {
|
||||
val fileName = importedFontFileName(
|
||||
displayName = "Pliant-Italic-VariableFont_wdth,wght",
|
||||
extension = "TTF"
|
||||
)
|
||||
|
||||
assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedFontFileNameRemovesPathUnsafeCharacters() {
|
||||
val fileName = importedFontFileName(
|
||||
displayName = """Pliant/Italic:VariableFont*wdth?wght""",
|
||||
extension = "t/tf"
|
||||
)
|
||||
|
||||
assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedFontFileNameFallsBackForBlankNames() {
|
||||
assertTrue(importedFontFileName("...", "ttf").startsWith("font."))
|
||||
}
|
||||
}
|
||||
|
|
@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
|
|||
assertTrue(item.isRecent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
|
||||
val longDescription = "Summary ".repeat(2_000)
|
||||
val longOriginalDescription = "Original ".repeat(2_000)
|
||||
dao.insertOrUpdateFile(
|
||||
recentFileEntity().copy(
|
||||
description = longDescription,
|
||||
originalDescription = longOriginalDescription
|
||||
)
|
||||
)
|
||||
|
||||
val summary = dao.getRecentFiles().first().single()
|
||||
val full = dao.getFileByBookId("book-1")!!
|
||||
|
||||
assertEquals(4_096, summary.description?.length)
|
||||
assertEquals(4_096, summary.originalDescription?.length)
|
||||
assertEquals(longDescription, full.description)
|
||||
assertEquals(longOriginalDescription, full.originalDescription)
|
||||
}
|
||||
|
||||
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
|
||||
return RecentFileEntity(
|
||||
bookId = "book-1",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,30 @@ class EpubParserUnitTest {
|
|||
assertTrue(extractionDir.list().isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
|
||||
val cacheDir = temp.newFolder("cache-merged-toc")
|
||||
val extractionDir = temp.newFolder("extract-merged-toc")
|
||||
val parser = EpubParser(contextWithCache(cacheDir))
|
||||
|
||||
val book = parser.createEpubBook(
|
||||
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
|
||||
bookId = "book-id",
|
||||
shouldUseToc = true,
|
||||
originalBookNameHint = "merged.epub",
|
||||
parseContent = true,
|
||||
extractionDirOverride = extractionDir
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
|
||||
book.tableOfContents.map { it.label }
|
||||
)
|
||||
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
|
||||
assertEquals("Volume 2", book.chapters[2].title)
|
||||
assertEquals("Chapter 2", book.chapters[3].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
||||
val cacheDir = temp.newFolder("cache-metadata-stream")
|
||||
|
|
@ -449,6 +473,50 @@ class EpubParserUnitTest {
|
|||
"OEBPS/images/unlisted.png" to "not-real-image"
|
||||
)
|
||||
|
||||
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||
""".trimIndent(),
|
||||
"OEBPS/content.opf" to """
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata><dc:title>Merged Volumes</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="v1title"/>
|
||||
<itemref idref="v1c1"/>
|
||||
<itemref idref="v2title"/>
|
||||
<itemref idref="v2c1"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent(),
|
||||
"OEBPS/1/toc.ncx" to """
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent(),
|
||||
"OEBPS/toc.ncx" to """
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent(),
|
||||
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
|
||||
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
|
||||
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
|
||||
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
|
||||
)
|
||||
|
||||
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class EpubReaderTtsHighlightAssetTest {
|
||||
|
||||
@Test
|
||||
fun `tts highlight is constrained to one readable block and does not inherit spacing`() {
|
||||
val js = epubReaderAsset().readText()
|
||||
|
||||
assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR"))
|
||||
assertTrue(js.contains("getTtsHighlightBlock(baseNode)"))
|
||||
assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT"))
|
||||
assertTrue(js.contains("text-align-last: auto !important;"))
|
||||
assertTrue(js.contains("letter-spacing: normal !important;"))
|
||||
assertTrue(js.contains("word-spacing: normal !important;"))
|
||||
}
|
||||
|
||||
private fun epubReaderAsset(): File {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/epub_reader.js"),
|
||||
File("app/src/main/assets/epub_reader.js")
|
||||
)
|
||||
return candidates.firstOrNull { it.isFile }
|
||||
?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}")
|
||||
}
|
||||
}
|
||||
|
|
@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
|
|||
|
||||
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
|
||||
val chunks = listOf(
|
||||
TtsChunk("Loaded one", "/4/2", 0),
|
||||
TtsChunk("Loaded two", "/4/4", 0),
|
||||
TtsChunk("Remaining three", "/4/6", 0),
|
||||
TtsChunk("Remaining four", "/4/8", 0)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
resolveTtsContinuationStartIndex(
|
||||
chunks = chunks,
|
||||
loadedChunkCount = 2,
|
||||
sourceCfi = "/does/not/match",
|
||||
startOffsetInSource = 0,
|
||||
currentText = "not present"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical continuation starts after matched spoken chunk`() {
|
||||
val chunks = listOf(
|
||||
TtsChunk("Loaded one", "/4/2", 0),
|
||||
TtsChunk("Loaded two", "/4/4", 0),
|
||||
TtsChunk("Remaining three", "/4/6", 0)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
resolveTtsContinuationStartIndex(
|
||||
chunks = chunks,
|
||||
loadedChunkCount = 1,
|
||||
sourceCfi = "/4/4",
|
||||
startOffsetInSource = 0,
|
||||
currentText = "Loaded two"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidEpubKeyCommandsTest {
|
||||
@Test
|
||||
fun `left and right map to page changes`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left and right respect right to left pagination`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up and down map to vertical scroll`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.SCROLL_UP,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.SCROLL_DOWN,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page home and end keys map to reader navigation`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.FIRST_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.LAST_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl shortcuts are left for reader chrome and search handling`() {
|
||||
assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class EpubFontFaceSiblingsTest {
|
||||
|
||||
@Test
|
||||
fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Literata-Regular.ttf").writeText("regular")
|
||||
File(fontsDir, "Literata-Italic.ttf").writeText("italic")
|
||||
File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic")
|
||||
File(fontsDir, "Other-Italic.ttf").writeText("other")
|
||||
|
||||
val expanded = expandFontFacesWithSiblings(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "literata",
|
||||
src = "OEBPS/fonts/Literata-Regular.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertEquals(3, expanded.size)
|
||||
assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic })
|
||||
assertTrue(
|
||||
expanded.any {
|
||||
it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" &&
|
||||
it.fontStyle == FontStyle.Italic &&
|
||||
it.fontWeight == FontWeight.Bold
|
||||
}
|
||||
)
|
||||
assertTrue(expanded.none { it.src.contains("Other") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "LoraRegular.ttf").writeText("regular")
|
||||
File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic")
|
||||
|
||||
val css = buildEpubFontFaceCss(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "lora",
|
||||
src = "fonts/LoraRegular.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertTrue(css.contains("font-family: 'lora'"))
|
||||
assertTrue(css.contains("font-weight: 700"))
|
||||
assertTrue(css.contains("font-style: italic"))
|
||||
assertTrue(css.contains("LoraBoldItalic.ttf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||
|
||||
val expanded = expandFontFacesWithSiblings(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "pliant",
|
||||
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertEquals(2, expanded.size)
|
||||
assertTrue(
|
||||
expanded.any {
|
||||
it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" &&
|
||||
it.fontStyle == FontStyle.Italic &&
|
||||
it.fontWeight == FontWeight.Normal
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||
|
||||
val css = buildEpubFontFaceCss(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "pliant",
|
||||
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertTrue(css.contains("font-weight: 100 900"))
|
||||
assertTrue(css.contains("font-style: italic"))
|
||||
assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf"))
|
||||
}
|
||||
|
||||
private fun createTempRoot(): File {
|
||||
return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also {
|
||||
it.deleteOnExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeVerticalLocationTest {
|
||||
|
|
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
|
|||
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
|
||||
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll progress updates within visible item offset`() {
|
||||
val weights = listOf(100, 300, 600)
|
||||
|
||||
assertEquals(
|
||||
25f,
|
||||
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights = weights,
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 500,
|
||||
firstVisibleItemSize = 1000
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
assertEquals(
|
||||
40f,
|
||||
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights = weights,
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 1000,
|
||||
firstVisibleItemSize = 1000
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info uses chapter local locator offset`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset = 500,
|
||||
chapterLengthChars = 1000,
|
||||
chapterPageCount = 11,
|
||||
compatPageIndex = 900,
|
||||
chapterStartPageIndex = 850
|
||||
)
|
||||
|
||||
assertEquals(6, pageInfo?.currentPage)
|
||||
assertEquals(11, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info falls back to absolute page within chapter`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset = null,
|
||||
chapterLengthChars = 0,
|
||||
chapterPageCount = 7,
|
||||
compatPageIndex = 24,
|
||||
chapterStartPageIndex = 20
|
||||
)
|
||||
|
||||
assertEquals(5, pageInfo?.currentPage)
|
||||
assertEquals(7, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info follows scroll weight within current chapter`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfoForScroll(
|
||||
itemChapterIndices = listOf(0, 0, 1, 1),
|
||||
itemWeights = listOf(100, 300, 100, 300),
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 500,
|
||||
firstVisibleItemSize = 1000,
|
||||
chapterPageCount = 9
|
||||
)
|
||||
|
||||
assertEquals(6, pageInfo?.currentPage)
|
||||
assertEquals(9, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
|
||||
val model = nativeVerticalImageModelData(
|
||||
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
|
||||
)
|
||||
|
||||
assertTrue(model is SvgData)
|
||||
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical svg data uri decoding preserves plus signs`() {
|
||||
assertEquals(
|
||||
"""<svg><path d="M1+2"/></svg>""",
|
||||
nativeVerticalSvgContentFromDataUri(
|
||||
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical persistence locator prefers visible text range`() {
|
||||
val location = NativeVerticalLocation(
|
||||
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
|
||||
chapterIndex = 2,
|
||||
progressPercent = 42f,
|
||||
compatPageIndex = 20,
|
||||
compatTotalPages = 100,
|
||||
firstVisibleItemIndex = 4,
|
||||
firstVisibleItemScrollOffset = 250,
|
||||
firstVisibleItemSize = 1000,
|
||||
isAtStart = false,
|
||||
isAtEnd = false,
|
||||
visibleTextRanges = listOf(
|
||||
NativeVerticalVisibleTextRange(
|
||||
chapterIndex = 2,
|
||||
blockIndex = 10,
|
||||
startCharOffset = 380,
|
||||
endCharOffset = 520
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator = true,
|
||||
didLocatorScroll = false
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator = false,
|
||||
didLocatorScroll = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical tts follow centers target offset in viewport`() {
|
||||
assertEquals(
|
||||
100f,
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = 500f,
|
||||
viewportHeight = 800f
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
assertEquals(
|
||||
-200f,
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = 200f,
|
||||
viewportHeight = 800f
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
|
|||
@Test
|
||||
fun `native vertical initial prefetch is bounded around requested chapter`() {
|
||||
assertEquals(
|
||||
listOf(4, 5, 2),
|
||||
listOf(4, 5),
|
||||
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
|
|||
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
|
||||
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
|
||||
|
||||
assertTrue(pdfBytesContainEncryptMarker(bytes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf encrypt marker detection ignores longer pdf names`() {
|
||||
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
|
||||
|
||||
assertFalse(pdfBytesContainEncryptMarker(bytes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFastFileId uses stable file name and length for file uris`() {
|
||||
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
|
||||
|
|
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
|
|||
assertTrue(limitedScale >= 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
|
||||
val slotWidth = pdfSpreadPageSlotWidth(
|
||||
containerWidth = 1920f,
|
||||
containerHeight = 900f,
|
||||
pageGap = 0f,
|
||||
spreadPageCount = 2,
|
||||
pageAspectRatio = 612f / 792f
|
||||
)
|
||||
|
||||
assertEquals(695.4545f, slotWidth, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread page slot width caps pages to available spread width`() {
|
||||
val slotWidth = pdfSpreadPageSlotWidth(
|
||||
containerWidth = 1000f,
|
||||
containerHeight = 900f,
|
||||
pageGap = 20f,
|
||||
spreadPageCount = 2,
|
||||
pageAspectRatio = 1.4f
|
||||
)
|
||||
|
||||
assertEquals(490f, slotWidth, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
|
||||
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
|
||||
|
|
|
|||
|
|
@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
|
|||
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
|
||||
val sections = pdfOverflowMenuSections(
|
||||
hiddenTools = setOf(
|
||||
PdfReaderTool.SHARE.name,
|
||||
PdfReaderTool.SAVE_COPY.name
|
||||
),
|
||||
hasHiddenToolbarTools = false,
|
||||
isPro = false,
|
||||
effectiveFileType = FileType.PDF,
|
||||
hasFileInfo = false,
|
||||
canPrintDocument = false
|
||||
)
|
||||
|
||||
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf overflow sections expose file info only when available and visible`() {
|
||||
val visibleSections = pdfOverflowMenuSections(
|
||||
|
|
|
|||
|
|
@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 0.82f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1f,
|
||||
targetWidthPx = 3200,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated pdf high res tiles keep existing zoom threshold`() {
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 0.82f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1.25f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1.25f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom indicator percent rounds displayed scale`() {
|
||||
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
|
||||
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
|
||||
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom indicator hides only at displayed one hundred percent`() {
|
||||
assertFalse(shouldShowPdfZoomIndicator(100))
|
||||
assertTrue(shouldShowPdfZoomIndicator(99))
|
||||
assertTrue(shouldShowPdfZoomIndicator(125))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page change preserves locked zoom scale only in paginated lock mode`() {
|
||||
val lockedState = Triple(2.25f, -12f, 32f)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.junit.Test
|
|||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class TtsChunkNavigationTest {
|
||||
@Test
|
||||
|
|
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
|
|||
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldAdvanceToTtsPlaylistChunk(
|
||||
currentChunkIndex = 8,
|
||||
playlistChunkIndex = 10,
|
||||
skippedChunkIndices = setOf(9)
|
||||
)
|
||||
)
|
||||
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunk generation gives up after bounded failures`() {
|
||||
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
|
||||
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transition prefetch is deferred only for the rebuilding generation`() {
|
||||
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
|
||||
|
|
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
|
|||
assertNull(estimateTtsNotificationDurationMs(text = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stable sorted snapshot copies concurrent cache keys`() {
|
||||
val cache = ConcurrentHashMap<Int, String>()
|
||||
cache[3] = "three"
|
||||
cache[1] = "one"
|
||||
cache[2] = "two"
|
||||
|
||||
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav file duration is read from pcm byte rate`() {
|
||||
val file = createTempWavFile(pcmBytes = 48_000)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue