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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue