General improvements (#50)

* Implemented long-press gestures(on the left and right region) for rapid scrolling(to top or bottom) in Musician Mode for both EPUB and PDF readers.

* Added support for book-specific reader settings and redesigned the format adjustment UI.

* perf: implement persistent caching and parallel parsing for single-file imports

- Replaced random UUID generation with bookId-based pathing in SingleFileImporter to enable cache reuse.
- Added a JSON metadata fast-path to return EpubBook objects instantly on subsequent opens for MD, TXT, and HTML.
- Parallelized Markdown chapter parsing using Coroutines (async/awaitAll) to leverage multicore CPUs.
This commit is contained in:
Aryan 2026-03-09 23:09:56 +05:30 committed by GitHub
parent ef1bfdc57a
commit acf282d4c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 684 additions and 299 deletions

View file

@ -29,7 +29,12 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -37,19 +42,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
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.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@ -84,6 +86,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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.data.CustomFontEntity
@ -116,6 +119,89 @@ enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId:
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
}
data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
)
private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, false)
}
fun saveFormatIsLocal(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, isLocal) }
}
fun saveLocalReaderSettings(
context: Context,
bookId: String,
fontSize: Float,
lineHeight: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, fontFamily.id)
}
putString(LOCAL_TEXT_ALIGN_PREFIX + bookId, textAlign.id)
}
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val fontSize = if (isLocal && prefs.contains(LOCAL_FONT_SIZE_PREFIX + bookId)) {
prefs.getFloat(LOCAL_FONT_SIZE_PREFIX + bookId, DEFAULT_FONT_SIZE_VAL)
} else {
prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL)
}
val lineHeight = if (isLocal && prefs.contains(LOCAL_LINE_HEIGHT_PREFIX + bookId)) {
prefs.getFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, DEFAULT_LINE_HEIGHT_VAL)
} else {
prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL)
}
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else {
prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
}
val (font, customPath) = if (savedFontVal.startsWith("custom|")) {
Pair(ReaderFont.ORIGINAL, savedFontVal.substringAfter("custom|"))
} else {
Pair(ReaderFont.entries.find { it.id == savedFontVal } ?: ReaderFont.ORIGINAL, null)
}
val alignId = if (isLocal && prefs.contains(LOCAL_TEXT_ALIGN_PREFIX + bookId)) {
prefs.getString(LOCAL_TEXT_ALIGN_PREFIX + bookId, ReaderTextAlign.DEFAULT.id)
} else {
prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id)
}
val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT
return FormatSettings(fontSize, lineHeight, font, customPath, textAlign)
}
fun getComposeFontFamily(
font: ReaderFont,
customFontPath: String? = null,
@ -147,19 +233,6 @@ fun getComposeFontFamily(
return FontFamily.Default
}
fun loadFontSelection(context: Context): Pair<ReaderFont, String?> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedVal = prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
return if (savedVal.startsWith("custom|")) {
val path = savedVal.substringAfter("custom|")
Pair(ReaderFont.ORIGINAL, path)
} else {
val font = ReaderFont.entries.find { it.id == savedVal } ?: ReaderFont.ORIGINAL
Pair(font, null)
}
}
fun saveReaderSettings(
context: Context,
fontSize: Float,
@ -181,22 +254,6 @@ fun saveReaderSettings(
}
}
fun loadFontSize(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL)
}
fun loadLineHeight(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL)
}
fun loadTextAlign(context: Context): ReaderTextAlign {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id)
return ReaderTextAlign.entries.find { it.id == id } ?: ReaderTextAlign.DEFAULT
}
fun saveAutoScrollSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) }
@ -240,6 +297,9 @@ fun ReaderTextFormatPanel(
currentTextAlign: ReaderTextAlign,
onTextAlignChange: (ReaderTextAlign) -> Unit,
onReset: () -> Unit,
isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@ -249,40 +309,106 @@ fun ReaderTextFormatPanel(
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
shadowElevation = 8.dp,
modifier = Modifier.fillMaxWidth()
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.95f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Font Family", style = MaterialTheme.typography.labelLarge)
Box {
var showModeMenu by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showModeMenu = true }
.padding(4.dp)
) {
Text(
text = if (isLocalMode) "Local Format" else "Global Format",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Select Mode",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
DropdownMenu(expanded = showModeMenu, onDismissRequest = { showModeMenu = false }) {
DropdownMenuItem(
text = {
Column {
Text("Global Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Applies to all files", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = { onLocalModeToggle(false); showModeMenu = false },
trailingIcon = { if (!isLocalMode) Icon(Icons.Default.Check, null) }
)
HorizontalDivider()
DropdownMenuItem(
text = {
Column {
Text("Local Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
Text("Saved for this file only", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
onClick = { onLocalModeToggle(true); showModeMenu = false },
trailingIcon = { if (isLocalMode) Icon(Icons.Default.Check, null) }
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onReset, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text("Reset")
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Close", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(8.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(40.dp)
modifier = Modifier
.weight(0.45f)
.height(48.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 12.dp)
) {
val displayName = currentCustomFontName ?: currentFont.displayName
Text(
text = displayName,
text = currentCustomFontName ?: currentFont.displayName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
@ -291,117 +417,69 @@ fun ReaderTextFormatPanel(
)
}
}
}
HorizontalDivider()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.weight(0.55f)
.height(48.dp)
) {
Text("Font Size", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentFontSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.fillMaxWidth()
)
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Line Spacing", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentLineHeight),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f,
steps = 14,
modifier = Modifier.fillMaxWidth()
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box {
var alignmentMenuExpanded by remember { mutableStateOf(false) }
Surface(
onClick = { alignmentMenuExpanded = true },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
painter = androidx.compose.ui.res.painterResource(id = currentTextAlign.iconResId),
contentDescription = "Text Alignment",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
DropdownMenu(
expanded = alignmentMenuExpanded,
onDismissRequest = { alignmentMenuExpanded = false }
) {
Row {
ReaderTextAlign.entries.forEach { align ->
DropdownMenuItem(
text = { Text(align.displayName) },
leadingIcon = {
Icon(
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
contentDescription = null
)
},
trailingIcon = {
if (align == currentTextAlign) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
},
onClick = {
onTextAlignChange(align)
alignmentMenuExpanded = false
}
)
val isSelected = currentTextAlign == align
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onTextAlignChange(align) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
contentDescription = align.displayName,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(
text = align.displayName,
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
TextButton(onClick = onReset) {
Text("Reset Defaults")
Spacer(Modifier.height(16.dp))
// Sliders
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp))
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.weight(1f)
)
Text("%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Spacing", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp))
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f,
steps = 14,
modifier = Modifier.weight(1f)
)
Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End)
}
}
}
@ -485,7 +563,7 @@ fun FontSelectionSheetContent(
items(customFonts) { fontEntity ->
val isSelected = currentCustomFontPath == fontEntity.path
val fontFamily = remember(fontEntity.path) {
try { FontFamily(androidx.compose.ui.text.font.Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
}
ListItem(