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

@ -2148,6 +2148,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isRecent: Boolean,
sourceFolderUri: String? = null
) = withContext(Dispatchers.IO) {
val addStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}")
val isNewBook = withContext(Dispatchers.IO) {
recentFilesRepository.getFileByBookId(bookId) == null
}
@ -2165,6 +2167,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) {
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
val parseStart = System.currentTimeMillis()
try {
importMutex.withLock {
bookForMetadata = withContext(Dispatchers.IO) {
@ -2188,13 +2192,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
singleFileImporter.importSingleFile(
inputStream,
type,
originalBookNameHint = displayName
originalBookNameHint = displayName,
bookId = bookId
)
}
}
}
}
}
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms")
} catch (e: Exception) {
Timber.e(
e,
@ -2202,6 +2208,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
bookForMetadata = null
}
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms")
}
val finalBookMetadata = bookForMetadata
@ -2389,7 +2396,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null
) {
Timber.d("Opening book type: $type for bookId: $bookId")
val openBookStartTime = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
}
}
} catch (e: Exception) {
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
viewModelScope.launch {
_internalState.update {
@ -2416,7 +2439,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
Timber.d("openBook: Loading PDF. bookId=$bookId ...")
Timber.tag("FileOpenPerf").d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
_internalState.update {
it.copy(
selectedPdfUri = uri,
@ -2442,6 +2465,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.tag("FileOpenPerf").d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator =
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
Locator(
@ -2486,6 +2510,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun loadSingleFile(uri: Uri, bookId: String, type: FileType, customDisplayName: String? = null) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
@ -2497,19 +2523,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (inputStream == null) {
throw Exception("Could not open input stream for URI")
}
// singleFileImporter is now actually an instance of SingleFileImporter (see below)
// You will need to rename the class inside SingleFileImporter.kt
singleFileImporter.importSingleFile(
inputStream,
type,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri,
appContext
) ?: "unknown_doc"
) ?: "unknown_doc",
bookId = bookId
)
}
}
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,
@ -2522,6 +2548,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) {
Timber.e(e, "Error parsing file ($type) for URI: $uri")
_internalState.update {
@ -2620,6 +2647,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
@ -2641,6 +2670,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
Timber.i("EPUB parsing successful. Title: ${epubBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri,
@ -2653,6 +2683,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) {
Timber.e(e, "Error parsing EPUB for URI: $uri")
_internalState.update {

View file

@ -30,39 +30,65 @@ import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class SingleFileImporter(private val context: Context) {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
originalBookNameHint: String
originalBookNameHint: String,
bookId: String
): EpubBook {
return when (type) {
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint)
else -> parsePlainText(inputStream, originalBookNameHint) // Fallback
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId)
else -> parsePlainText(inputStream, originalBookNameHint, bookId) // Fallback
}
}
private suspend fun parseMarkdown(
inputStream: InputStream,
originalBookNameHint: String
originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached MD, parsing again")
}
}
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint")
Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".")
// Read the full Markdown content
val markdownContent = inputStream.bufferedReader().use { it.readText() }
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Read ${markdownContent.length} chars | elapsed=${System.currentTimeMillis() - parseStart}ms")
// Flexmark Setup
val options = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf(
@ -95,15 +121,11 @@ class SingleFileImporter(private val context: Context) {
markdownContent.split("\n\n---\n\n")
}
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "imported_md_$bookId").apply {
if (!exists()) mkdirs()
}
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Split into ${rawChapters.size} raw chapters | elapsed=${System.currentTimeMillis() - parseStart}ms")
val chapters = mutableListOf<EpubChapter>()
rawChapters.forEachIndexed { index, rawText ->
if (rawText.isBlank()) return@forEachIndexed
val chapters = rawChapters.mapIndexed { index, rawText ->
async(Dispatchers.Default) {
if (rawText.isBlank()) return@async null
val pageNum = index + 1
val chapterTitle = "Page $pageNum"
@ -129,7 +151,7 @@ class SingleFileImporter(private val context: Context) {
file.writeText(fullHtml)
chapters.add(EpubChapter(
EpubChapter(
chapterId = "${bookId}_$pageNum",
absPath = fileName,
title = chapterTitle,
@ -138,12 +160,14 @@ class SingleFileImporter(private val context: Context) {
htmlContent = "",
depth = 0,
isInToc = true
))
)
}
}.awaitAll().filterNotNull()
Timber.d("Markdown import complete. Created ${chapters.size} chapters (one per page).")
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown COMPLETE | chapters=${chapters.size} | totalElapsed=${System.currentTimeMillis() - parseStart}ms")
return@withContext EpubBook(
val book = EpubBook(
fileName = originalBookNameHint,
title = title,
author = "Unknown",
@ -156,19 +180,40 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache MD metadata")
}
return@withContext book
}
private suspend fun parsePlainText(
inputStream: InputStream,
originalBookNameHint: String
originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Parsing Plain Text with Virtual Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".")
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "imported_txt_$bookId").apply {
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached TXT, parsing again")
}
}
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint")
Timber.d("Parsing Plain Text with Virtual Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".")
val chapters = mutableListOf<EpubChapter>()
var chapterCounter = 1
@ -276,7 +321,9 @@ class SingleFileImporter(private val context: Context) {
Timber.d("Imported TXT split into ${chapters.size} chapters.")
return@withContext EpubBook(
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText COMPLETE | chapters=${chapters.size} | totalElapsed=${System.currentTimeMillis() - parseStart}ms")
val book = EpubBook(
fileName = originalBookNameHint,
title = title,
author = "Unknown",
@ -289,24 +336,52 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache TXT metadata")
}
return@withContext book
}
private suspend fun parseHtml(
inputStream: InputStream,
originalBookNameHint: String
originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached HTML, parsing again")
}
}
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
Timber.d("Importing HTML: $originalBookNameHint")
val content = inputStream.bufferedReader().use { it.readText() }
val doc = Jsoup.parse(content)
val title = doc.title().takeIf { it.isNotBlank() } ?: originalBookNameHint.substringBeforeLast(".")
Timber.tag("FileOpenPerf").d("[HTML] parseHtml: Read ${content.length} chars | elapsed=${System.currentTimeMillis() - parseStart}ms")
val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() }
?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() }
val finalHtml = doc.outerHtml()
createBookFromHtmlBody(title, null, null, originalBookNameHint, preGeneratedFullHtml = finalHtml, author = author)
Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms")
createBookFromHtmlBody(title, null, null, originalBookNameHint, bookId, extractionDir, metadataFile, preGeneratedFullHtml = finalHtml, author = author)
}
private fun createBookFromHtmlBody(
@ -314,6 +389,9 @@ class SingleFileImporter(private val context: Context) {
@Suppress("SameParameterValue") bodyContent: String?,
@Suppress("SameParameterValue")cssStyle: String?,
fileName: String,
bookId: String,
extractionDir: File,
metadataFile: File,
preGeneratedFullHtml: String? = null,
author: String? = null
): EpubBook {
@ -334,11 +412,6 @@ class SingleFileImporter(private val context: Context) {
val plainText = Jsoup.parse(fullHtml).text()
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "single_file_cache_$bookId").apply {
if (!exists()) mkdirs()
}
try {
File(extractionDir, "content.html").writeText(fullHtml)
} catch (e: Exception) {
@ -356,7 +429,7 @@ class SingleFileImporter(private val context: Context) {
isInToc = true
)
return EpubBook(
val book = EpubBook(
fileName = fileName,
title = title,
author = author ?: "",
@ -369,5 +442,13 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache HTML metadata")
}
return book
}
}

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 = {
.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
scope.launch {
webViewRefForTts?.evaluateJavascript(
"window.scrollTo({ top: 0, behavior: 'auto' });", null
)
}
break
}
leftHoldProgress = elapsed / 1000f
delay(16)
}
}
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,25 +2950,69 @@ 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 = {
.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)
scope.launch {
webViewRefForTts?.evaluateJavascript(
"window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' });", null
)
}
break
}
rightHoldProgress = elapsed / 1000f
delay(16)
}
}
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
)
}
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
EpubReaderSearchOverlay(
@ -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)
Row {
ReaderTextAlign.entries.forEach { align ->
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(
"%.1fx".format(currentFontSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
text = align.displayName,
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
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.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
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.fillMaxWidth()
modifier = Modifier.weight(1f)
)
}
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 }
) {
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
}
)
}
}
}
TextButton(onClick = onReset) {
Text("Reset Defaults")
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(

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com
*/
// PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable")
package com.aryan.reader.pdf
@ -27,6 +27,9 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.graphics.Bitmap
import kotlin.math.max
import android.graphics.RectF
@ -4286,6 +4289,10 @@ fun PdfViewerScreen(
var leftPulseTrigger by remember { mutableLongStateOf(0L) }
var rightPulseTrigger by remember { mutableLongStateOf(0L) }
// --- ADD THESE STATES ---
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"
@ -4302,6 +4309,7 @@ fun PdfViewerScreen(
val scrollAmount = boxMaxHeightFloat * 0.75f
// Left Region
Box(
modifier = regionWidth
.then(regionHeight)
@ -4310,22 +4318,65 @@ fun PdfViewerScreen(
.padding(start = 8.dp)
.background(Color.White.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 = {
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
var isLongPress = false
val job = coroutineScope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
val elapsed = System.currentTimeMillis() - startTime
if (elapsed >= 1000) {
leftHoldProgress = 0f
isLongPress = true
leftPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(1000L)
coroutineScope.launch {
verticalReaderState.scrollToPage(0)
}
break
}
leftHoldProgress = elapsed / 1000f
delay(16)
}
}
val up = waitForUpOrCancellation()
job.cancel()
leftHoldProgress = 0f
if (!isLongPress && up != null) {
up.consume()
Timber.tag("MusicianMode").d("Left region tapped")
leftPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(600L)
coroutineScope.launch {
verticalReaderState.scrollBy(-scrollAmount)
}
}
}
},
contentAlignment = Alignment.Center
) {
if (leftHoldProgress > 0f) {
CircularProgressIndicator(
progress = { leftHoldProgress },
modifier = Modifier.size(48.dp).alpha(0.6f),
color = MaterialTheme.colorScheme.primary,
trackColor = Color.Transparent,
strokeWidth = 4.dp
)
Icon(
imageVector = Icons.Default.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(24.dp).alpha(0.6f),
tint = MaterialTheme.colorScheme.primary
)
}
}
// Right Region
Box(
modifier = regionWidth
.then(regionHeight)
@ -4334,24 +4385,65 @@ fun PdfViewerScreen(
.padding(end = 8.dp)
.background(Color.White.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 = {
.pointerInput(totalPages) {
awaitEachGesture {
val down = awaitFirstDown()
var isLongPress = false
val job = coroutineScope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
val elapsed = System.currentTimeMillis() - startTime
if (elapsed >= 1000) {
rightHoldProgress = 0f
isLongPress = true
rightPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(1000L)
coroutineScope.launch {
verticalReaderState.scrollToPage(totalPages - 1)
}
break
}
rightHoldProgress = elapsed / 1000f
delay(16)
}
}
val up = waitForUpOrCancellation()
job.cancel()
rightHoldProgress = 0f
if (!isLongPress && up != null) {
up.consume()
Timber.tag("MusicianMode").d("Right region tapped")
rightPulseTrigger = System.currentTimeMillis() // Trigger flash
// Pause loop to allow smooth scroll
rightPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(600L)
coroutineScope.launch {
verticalReaderState.scrollBy(scrollAmount)
}
}
}
},
contentAlignment = Alignment.Center
) {
if (rightHoldProgress > 0f) {
CircularProgressIndicator(
progress = { rightHoldProgress },
modifier = Modifier.size(48.dp).alpha(0.6f),
color = MaterialTheme.colorScheme.primary,
trackColor = Color.Transparent,
strokeWidth = 4.dp
)
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = null,
modifier = Modifier.size(24.dp).alpha(0.6f),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
// OCR language download indicator
AnimatedVisibility(