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

@ -37,7 +37,7 @@ data class ChapterLoadingResult(
/**
* loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc).
* the initial chunk to display based on navigation state (CFI, overrides, etc.).
*/
suspend fun loadChapterContent(
epubBook: EpubBook,
@ -47,10 +47,10 @@ suspend fun loadChapterContent(
cfiToLoad: String?,
locatorConverter: LocatorConverter
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter = epubBook.chapters.getOrNull(chapterIndex)
if (chapter == null) {
return@withContext ChapterLoadingResult("", emptyList(), 0, false, "Chapter index out of bounds")
}
val chapter =
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
"", emptyList(), 0, false, "Chapter index out of bounds"
)
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
@ -60,11 +60,9 @@ suspend fun loadChapterContent(
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
val bodyChildren = doc.body().children().toList()
// Split into chunks of 20 elements
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
}
// Fallback for empty chapters
if (chunkedList.isEmpty()) {
head to listOf("<body><p>This chapter is empty.</p></body>")
} else {

View file

@ -18,7 +18,9 @@
* mail: epistemereader@gmail.com
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead")
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused"
)
package com.aryan.reader.epubreader
@ -29,6 +31,9 @@ import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.net.Uri
import android.os.Build
import android.webkit.WebView
@ -50,7 +55,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -65,6 +69,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth
@ -72,6 +77,8 @@ import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
@ -112,6 +119,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
@ -285,7 +293,7 @@ private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private fun loadUseOnlineDict(context: Context): Boolean {
if (BuildConfig.FLAVOR == "oss") return false
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
@ -403,7 +411,6 @@ fun EpubReaderHost(
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
var currentTextAlign by remember { mutableStateOf(loadTextAlign(context)) }
var chunkTargetOverride by remember { mutableStateOf<Int?>(null) }
@ -560,7 +567,7 @@ fun EpubReaderHost(
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
val onDictionaryLookup = { word: String ->
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
@ -759,13 +766,17 @@ fun EpubReaderHost(
}
}
var currentFontSizeEm by remember { mutableFloatStateOf(loadFontSize(context)) }
var currentLineHeight by remember { mutableFloatStateOf(loadLineHeight(context)) }
var showFormatAdjustmentBars by remember { mutableStateOf(false) }
val (initialFont, initialCustomPath) = remember { loadFontSelection(context) }
var currentFontFamily by remember { mutableStateOf(initialFont) }
var currentCustomFontPath by remember { mutableStateOf(initialCustomPath) }
var isFormatLocal by remember { mutableStateOf(loadFormatIsLocal(context, bookId)) }
val initialFormatSettings = remember(isFormatLocal) { loadFormatSettings(context, bookId, isFormatLocal) }
var currentFontSizeEm by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.fontSize) }
var currentLineHeight by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.lineHeight) }
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
val activeFontFamily = remember(currentFontFamily, currentCustomFontPath) {
getComposeFontFamily(
font = currentFontFamily,
@ -777,15 +788,16 @@ fun EpubReaderHost(
var showFontSelectionSheet by remember { mutableStateOf(false) }
val fontSheetState = rememberModalBottomSheetState()
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign) {
saveReaderSettings(
context,
currentFontSizeEm,
currentLineHeight,
currentFontFamily,
currentCustomFontPath,
currentTextAlign
)
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
if (isFormatLocal) {
saveLocalReaderSettings(
context, bookId, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
)
} else {
saveReaderSettings(
context, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
)
}
}
LaunchedEffect(bannerMessage) {
@ -2842,10 +2854,12 @@ fun EpubReaderHost(
if (isMusicianMode && isAutoScrollModeActive) {
val density = LocalDensity.current
// States for visual feedback
var leftPulseTrigger by remember { mutableLongStateOf(0L) }
var rightPulseTrigger by remember { mutableLongStateOf(0L) }
var leftHoldProgress by remember { mutableFloatStateOf(0f) }
var rightHoldProgress by remember { mutableFloatStateOf(0f) }
val leftPulseAlpha by animateFloatAsState(
targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f,
animationSpec = tween(150), label = "leftPulse"
@ -2867,24 +2881,67 @@ fun EpubReaderHost(
.align(Alignment.TopStart)
.offset(y = topOffset)
.padding(start = 8.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) // Pulse
.background(MaterialTheme.colorScheme.primary.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp))
.border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
leftPulseTrigger = System.currentTimeMillis()
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
var isLongPress = false
val job = scope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
val elapsed = System.currentTimeMillis() - startTime
if (elapsed >= 1000) {
leftHoldProgress = 0f
isLongPress = true
leftPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(1000L)
// Pause loop to allow smooth scroll
triggerAutoScrollTempPause(600L)
scope.launch {
webViewRefForTts?.evaluateJavascript(
"window.scrollTo({ top: 0, behavior: 'auto' });", null
)
}
break
}
leftHoldProgress = elapsed / 1000f
delay(16)
}
}
val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null
)
val up = waitForUpOrCancellation()
job.cancel()
leftHoldProgress = 0f
if (!isLongPress && up != null) {
up.consume()
leftPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(600L)
val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null
)
}
}
},
contentAlignment = Alignment.Center
) {
if (leftHoldProgress > 0f) {
CircularProgressIndicator(
progress = { leftHoldProgress },
modifier = Modifier.size(48.dp).alpha(0.6f),
color = MaterialTheme.colorScheme.onSurface,
trackColor = Color.Transparent,
strokeWidth = 4.dp
)
)
Icon(
imageVector = Icons.Default.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(24.dp).alpha(0.6f),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
// Right Region
Box(
@ -2893,23 +2950,67 @@ fun EpubReaderHost(
.align(Alignment.TopEnd)
.offset(y = topOffset)
.padding(end = 8.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) // Pulse
.background(MaterialTheme.colorScheme.primary.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp))
.border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
rightPulseTrigger = System.currentTimeMillis()
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
var isLongPress = false
val job = scope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
val elapsed = System.currentTimeMillis() - startTime
if (elapsed >= 1000) {
rightHoldProgress = 0f
isLongPress = true
rightPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(1000L)
triggerAutoScrollTempPause(600L)
scope.launch {
webViewRefForTts?.evaluateJavascript(
"window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' });", null
)
}
break
}
rightHoldProgress = elapsed / 1000f
delay(16)
}
}
val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null
)
val up = waitForUpOrCancellation()
job.cancel()
rightHoldProgress = 0f
if (!isLongPress && up != null) {
up.consume()
rightPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(600L)
val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null
)
}
}
},
contentAlignment = Alignment.Center
) {
if (rightHoldProgress > 0f) {
CircularProgressIndicator(
progress = { rightHoldProgress },
modifier = Modifier.size(48.dp).alpha(0.6f),
color = MaterialTheme.colorScheme.onSurface,
trackColor = Color.Transparent,
strokeWidth = 4.dp
)
)
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = null,
modifier = Modifier.size(24.dp).alpha(0.6f),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@ -3006,11 +3107,7 @@ fun EpubReaderHost(
onStartAutoScroll = {
isAutoScrollModeActive = true
isAutoScrollPlaying = true
showBars = if (isMusicianMode) {
false
} else {
true
}
showBars = !isMusicianMode
},
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
@ -3306,8 +3403,16 @@ fun EpubReaderHost(
currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT
},
modifier = Modifier.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding)
isLocalMode = isFormatLocal,
onLocalModeToggle = {
isFormatLocal = it
saveFormatIsLocal(context, bookId, it)
},
onClose = { showFormatAdjustmentBars = false },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding + 16.dp)
.padding(horizontal = 16.dp)
)
EpubReaderAiOverlays(

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(