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:
parent
ef1bfdc57a
commit
acf282d4c7
6 changed files with 684 additions and 299 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue