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, isRecent: Boolean,
sourceFolderUri: String? = null sourceFolderUri: String? = null
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val addStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}")
val isNewBook = withContext(Dispatchers.IO) { val isNewBook = withContext(Dispatchers.IO) {
recentFilesRepository.getFileByBookId(bookId) == null 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)) { 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.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 { try {
importMutex.withLock { importMutex.withLock {
bookForMetadata = withContext(Dispatchers.IO) { bookForMetadata = withContext(Dispatchers.IO) {
@ -2188,13 +2192,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
singleFileImporter.importSingleFile( singleFileImporter.importSingleFile(
inputStream, inputStream,
type, type,
originalBookNameHint = displayName originalBookNameHint = displayName,
bookId = bookId
) )
} }
} }
} }
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e( Timber.e(
e, e,
@ -2202,6 +2208,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
bookForMetadata = null bookForMetadata = null
} }
Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms")
} }
val finalBookMetadata = bookForMetadata val finalBookMetadata = bookForMetadata
@ -2389,7 +2396,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun openBook( private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null 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 { viewModelScope.launch {
_internalState.update { _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 { _internalState.update {
it.copy( it.copy(
selectedPdfUri = uri, selectedPdfUri = uri,
@ -2442,6 +2465,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.syncLocalMetadataToFolder(bookId) recentFilesRepository.syncLocalMetadataToFolder(bookId)
} }
} }
Timber.tag("FileOpenPerf").d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator = val locator =
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
Locator( Locator(
@ -2486,6 +2510,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun loadSingleFile(uri: Uri, bookId: String, type: FileType, customDisplayName: String? = null) { 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 { viewModelScope.launch {
if (!_internalState.value.isLoading) { if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) } _internalState.update { it.copy(isLoading = true, errorMessage = null) }
@ -2497,19 +2523,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (inputStream == null) { if (inputStream == null) {
throw Exception("Could not open input stream for URI") 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( singleFileImporter.importSingleFile(
inputStream, inputStream,
type, type,
originalBookNameHint = customDisplayName ?: getFileNameFromUri( originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, uri,
appContext 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}") Timber.i("Import successful ($type). Title: ${epubBook.title}")
addFileToRecent( addFileToRecent(
uri, uri,
@ -2522,6 +2548,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing file ($type) for URI: $uri") Timber.e(e, "Error parsing file ($type) for URI: $uri")
_internalState.update { _internalState.update {
@ -2620,6 +2647,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null) { private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch { viewModelScope.launch {
if (!_internalState.value.isLoading) { if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) } _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.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( addFileToRecent(
uri, uri,
@ -2653,6 +2683,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing EPUB for URI: $uri") Timber.e(e, "Error parsing EPUB for URI: $uri")
_internalState.update { _internalState.update {

View file

@ -30,39 +30,65 @@ import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet import com.vladsch.flexmark.util.data.MutableDataSet
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup import org.jsoup.Jsoup
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.UUID
class SingleFileImporter(private val context: Context) { class SingleFileImporter(private val context: Context) {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
suspend fun importSingleFile( suspend fun importSingleFile(
inputStream: InputStream, inputStream: InputStream,
type: FileType, type: FileType,
originalBookNameHint: String originalBookNameHint: String,
bookId: String
): EpubBook { ): EpubBook {
return when (type) { return when (type) {
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint) FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint) FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint) FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId)
else -> parsePlainText(inputStream, originalBookNameHint) // Fallback else -> parsePlainText(inputStream, originalBookNameHint, bookId) // Fallback
} }
} }
private suspend fun parseMarkdown( private suspend fun parseMarkdown(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) { ): 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") Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".") val title = originalBookNameHint.substringBeforeLast(".")
// Read the full Markdown content // Read the full Markdown content
val markdownContent = inputStream.bufferedReader().use { it.readText() } val markdownContent = inputStream.bufferedReader().use { it.readText() }
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Read ${markdownContent.length} chars | elapsed=${System.currentTimeMillis() - parseStart}ms")
// Flexmark Setup // Flexmark Setup
val options = MutableDataSet().apply { val options = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf( set(Parser.EXTENSIONS, listOf(
@ -95,15 +121,11 @@ class SingleFileImporter(private val context: Context) {
markdownContent.split("\n\n---\n\n") markdownContent.split("\n\n---\n\n")
} }
val bookId = UUID.randomUUID().toString() Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Split into ${rawChapters.size} raw chapters | elapsed=${System.currentTimeMillis() - parseStart}ms")
val extractionDir = File(context.cacheDir, "imported_md_$bookId").apply {
if (!exists()) mkdirs()
}
val chapters = mutableListOf<EpubChapter>() val chapters = rawChapters.mapIndexed { index, rawText ->
async(Dispatchers.Default) {
rawChapters.forEachIndexed { index, rawText -> if (rawText.isBlank()) return@async null
if (rawText.isBlank()) return@forEachIndexed
val pageNum = index + 1 val pageNum = index + 1
val chapterTitle = "Page $pageNum" val chapterTitle = "Page $pageNum"
@ -129,7 +151,7 @@ class SingleFileImporter(private val context: Context) {
file.writeText(fullHtml) file.writeText(fullHtml)
chapters.add(EpubChapter( EpubChapter(
chapterId = "${bookId}_$pageNum", chapterId = "${bookId}_$pageNum",
absPath = fileName, absPath = fileName,
title = chapterTitle, title = chapterTitle,
@ -138,12 +160,14 @@ class SingleFileImporter(private val context: Context) {
htmlContent = "", htmlContent = "",
depth = 0, depth = 0,
isInToc = true isInToc = true
)) )
} }
}.awaitAll().filterNotNull()
Timber.d("Markdown import complete. Created ${chapters.size} chapters (one per page).") 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, fileName = originalBookNameHint,
title = title, title = title,
author = "Unknown", author = "Unknown",
@ -156,19 +180,40 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath, extractionBasePath = extractionDir.absolutePath,
css = emptyMap() 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( private suspend fun parsePlainText(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) { ): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Parsing Plain Text with Virtual Chaptering: $originalBookNameHint") val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
val title = originalBookNameHint.substringBeforeLast(".")
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "imported_txt_$bookId").apply {
if (!exists()) mkdirs() 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>() val chapters = mutableListOf<EpubChapter>()
var chapterCounter = 1 var chapterCounter = 1
@ -276,7 +321,9 @@ class SingleFileImporter(private val context: Context) {
Timber.d("Imported TXT split into ${chapters.size} chapters.") 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, fileName = originalBookNameHint,
title = title, title = title,
author = "Unknown", author = "Unknown",
@ -289,24 +336,52 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath, extractionBasePath = extractionDir.absolutePath,
css = emptyMap() 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( private suspend fun parseHtml(
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String originalBookNameHint: String,
bookId: String
): EpubBook = withContext(Dispatchers.IO) { ): 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") Timber.d("Importing HTML: $originalBookNameHint")
val content = inputStream.bufferedReader().use { it.readText() } val content = inputStream.bufferedReader().use { it.readText() }
val doc = Jsoup.parse(content) val doc = Jsoup.parse(content)
val title = doc.title().takeIf { it.isNotBlank() } ?: originalBookNameHint.substringBeforeLast(".") 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() } val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() }
?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() } ?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() }
val finalHtml = doc.outerHtml() 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( private fun createBookFromHtmlBody(
@ -314,6 +389,9 @@ class SingleFileImporter(private val context: Context) {
@Suppress("SameParameterValue") bodyContent: String?, @Suppress("SameParameterValue") bodyContent: String?,
@Suppress("SameParameterValue")cssStyle: String?, @Suppress("SameParameterValue")cssStyle: String?,
fileName: String, fileName: String,
bookId: String,
extractionDir: File,
metadataFile: File,
preGeneratedFullHtml: String? = null, preGeneratedFullHtml: String? = null,
author: String? = null author: String? = null
): EpubBook { ): EpubBook {
@ -334,11 +412,6 @@ class SingleFileImporter(private val context: Context) {
val plainText = Jsoup.parse(fullHtml).text() 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 { try {
File(extractionDir, "content.html").writeText(fullHtml) File(extractionDir, "content.html").writeText(fullHtml)
} catch (e: Exception) { } catch (e: Exception) {
@ -356,7 +429,7 @@ class SingleFileImporter(private val context: Context) {
isInToc = true isInToc = true
) )
return EpubBook( val book = EpubBook(
fileName = fileName, fileName = fileName,
title = title, title = title,
author = author ?: "", author = author ?: "",
@ -369,5 +442,13 @@ class SingleFileImporter(private val context: Context) {
extractionBasePath = extractionDir.absolutePath, extractionBasePath = extractionDir.absolutePath,
css = emptyMap() 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 * 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( suspend fun loadChapterContent(
epubBook: EpubBook, epubBook: EpubBook,
@ -47,10 +47,10 @@ suspend fun loadChapterContent(
cfiToLoad: String?, cfiToLoad: String?,
locatorConverter: LocatorConverter locatorConverter: LocatorConverter
): ChapterLoadingResult = withContext(Dispatchers.IO) { ): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter = epubBook.chapters.getOrNull(chapterIndex) val chapter =
if (chapter == null) { epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
return@withContext ChapterLoadingResult("", emptyList(), 0, false, "Chapter index out of bounds") "", emptyList(), 0, false, "Chapter index out of bounds"
} )
try { try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
@ -60,11 +60,9 @@ suspend fun loadChapterContent(
val doc = Jsoup.parse(htmlFile, "UTF-8") val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html() val head = doc.head().html()
val bodyChildren = doc.body().children().toList() val bodyChildren = doc.body().children().toList()
// Split into chunks of 20 elements
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements -> val chunkedList = bodyChildren.chunked(20).map { chunkOfElements ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
} }
// Fallback for empty chapters
if (chunkedList.isEmpty()) { if (chunkedList.isEmpty()) {
head to listOf("<body><p>This chapter is empty.</p></body>") head to listOf("<body><p>This chapter is empty.</p></body>")
} else { } else {

View file

@ -18,7 +18,9 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// EpubReaderScreen.kt // EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead") @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused"
)
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
@ -29,6 +31,9 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.AudioManager 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.net.Uri
import android.os.Build import android.os.Build
import android.webkit.WebView import android.webkit.WebView
@ -50,7 +55,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth 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.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.material.icons.filled.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager 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 const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private fun loadUseOnlineDict(context: Context): Boolean { 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) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true) return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
} }
@ -403,7 +411,6 @@ fun EpubReaderHost(
var showJustifyWarningDialog by remember { mutableStateOf(false) } var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) }
var currentTextAlign by remember { mutableStateOf(loadTextAlign(context)) }
var chunkTargetOverride by remember { mutableStateOf<Int?>(null) } var chunkTargetOverride by remember { mutableStateOf<Int?>(null) }
@ -560,7 +567,7 @@ fun EpubReaderHost(
var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog 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 isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary 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) } var showFormatAdjustmentBars by remember { mutableStateOf(false) }
val (initialFont, initialCustomPath) = remember { loadFontSelection(context) } var isFormatLocal by remember { mutableStateOf(loadFormatIsLocal(context, bookId)) }
var currentFontFamily by remember { mutableStateOf(initialFont) } val initialFormatSettings = remember(isFormatLocal) { loadFormatSettings(context, bookId, isFormatLocal) }
var currentCustomFontPath by remember { mutableStateOf(initialCustomPath) }
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) { val activeFontFamily = remember(currentFontFamily, currentCustomFontPath) {
getComposeFontFamily( getComposeFontFamily(
font = currentFontFamily, font = currentFontFamily,
@ -777,15 +788,16 @@ fun EpubReaderHost(
var showFontSelectionSheet by remember { mutableStateOf(false) } var showFontSelectionSheet by remember { mutableStateOf(false) }
val fontSheetState = rememberModalBottomSheetState() val fontSheetState = rememberModalBottomSheetState()
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign) { LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
saveReaderSettings( if (isFormatLocal) {
context, saveLocalReaderSettings(
currentFontSizeEm, context, bookId, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
currentLineHeight,
currentFontFamily,
currentCustomFontPath,
currentTextAlign
) )
} else {
saveReaderSettings(
context, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
)
}
} }
LaunchedEffect(bannerMessage) { LaunchedEffect(bannerMessage) {
@ -2842,10 +2854,12 @@ fun EpubReaderHost(
if (isMusicianMode && isAutoScrollModeActive) { if (isMusicianMode && isAutoScrollModeActive) {
val density = LocalDensity.current val density = LocalDensity.current
// States for visual feedback
var leftPulseTrigger by remember { mutableLongStateOf(0L) } var leftPulseTrigger by remember { mutableLongStateOf(0L) }
var rightPulseTrigger 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( val leftPulseAlpha by animateFloatAsState(
targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f, targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f,
animationSpec = tween(150), label = "leftPulse" animationSpec = tween(150), label = "leftPulse"
@ -2867,24 +2881,67 @@ fun EpubReaderHost(
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset(y = topOffset) .offset(y = topOffset)
.padding(start = 8.dp) .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)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable( .pointerInput(Unit) {
interactionSource = remember { MutableInteractionSource() }, awaitEachGesture {
indication = null, val down = awaitFirstDown()
onClick = { 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() 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) triggerAutoScrollTempPause(600L)
val amount = (currentClientHeightValue * 0.75f).toInt() val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript( webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null "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 // Right Region
Box( Box(
@ -2893,25 +2950,69 @@ fun EpubReaderHost(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset(y = topOffset) .offset(y = topOffset)
.padding(end = 8.dp) .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)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable( .pointerInput(Unit) {
interactionSource = remember { MutableInteractionSource() }, awaitEachGesture {
indication = null, val down = awaitFirstDown()
onClick = { 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() 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) triggerAutoScrollTempPause(600L)
val amount = (currentClientHeightValue * 0.75f).toInt() val amount = (currentClientHeightValue * 0.75f).toInt()
webViewRefForTts?.evaluateJavascript( webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null "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()) { Box(modifier = Modifier.fillMaxSize()) {
EpubReaderSearchOverlay( EpubReaderSearchOverlay(
@ -3006,11 +3107,7 @@ fun EpubReaderHost(
onStartAutoScroll = { onStartAutoScroll = {
isAutoScrollModeActive = true isAutoScrollModeActive = true
isAutoScrollPlaying = true isAutoScrollPlaying = true
showBars = if (isMusicianMode) { showBars = !isMusicianMode
false
} else {
true
}
}, },
searchFocusRequester = searchFocusRequester, searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
@ -3306,8 +3403,16 @@ fun EpubReaderHost(
currentCustomFontPath = null currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT currentTextAlign = ReaderTextAlign.DEFAULT
}, },
modifier = Modifier.align(Alignment.BottomCenter) isLocalMode = isFormatLocal,
.padding(bottom = bottomPadding) 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( EpubReaderAiOverlays(

View file

@ -29,7 +29,12 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity 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") 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( fun getComposeFontFamily(
font: ReaderFont, font: ReaderFont,
customFontPath: String? = null, customFontPath: String? = null,
@ -147,19 +233,6 @@ fun getComposeFontFamily(
return FontFamily.Default 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( fun saveReaderSettings(
context: Context, context: Context,
fontSize: Float, 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) { fun saveAutoScrollSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) } prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) }
@ -240,6 +297,9 @@ fun ReaderTextFormatPanel(
currentTextAlign: ReaderTextAlign, currentTextAlign: ReaderTextAlign,
onTextAlignChange: (ReaderTextAlign) -> Unit, onTextAlignChange: (ReaderTextAlign) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( AnimatedVisibility(
@ -249,40 +309,106 @@ fun ReaderTextFormatPanel(
modifier = modifier modifier = modifier
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(28.dp),
tonalElevation = 8.dp, color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.95f),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), tonalElevation = 0.dp,
shadowElevation = 8.dp, shadowElevation = 0.dp,
modifier = Modifier.fillMaxWidth() border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
) { ) {
Column( Column(
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(16.dp)
verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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( Surface(
onClick = onFontOptionClick, onClick = onFontOptionClick,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer, color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(40.dp) modifier = Modifier
.weight(0.45f)
.height(48.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 12.dp) modifier = Modifier.padding(horizontal = 12.dp)
) { ) {
val displayName = currentCustomFontName ?: currentFont.displayName
Text( Text(
text = displayName, text = currentCustomFontName ?: currentFont.displayName,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1, overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.width(8.dp))
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null, contentDescription = null,
@ -291,117 +417,69 @@ fun ReaderTextFormatPanel(
) )
} }
} }
}
HorizontalDivider() Surface(
shape = RoundedCornerShape(12.dp),
Column { color = MaterialTheme.colorScheme.surfaceVariant,
Row( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .weight(0.55f)
horizontalArrangement = Arrangement.SpaceBetween, .height(48.dp)
verticalAlignment = Alignment.CenterVertically
) { ) {
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( Text(
"%.1fx".format(currentFontSize), text = align.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary 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( Slider(
value = currentFontSize, value = currentFontSize,
onValueChange = onFontSizeChange, onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f, valueRange = 0.5f..3.0f,
steps = 24, steps = 24,
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(1f)
)
}
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
) )
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( Slider(
value = currentLineHeight, value = currentLineHeight,
onValueChange = onLineHeightChange, onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f, valueRange = 1.0f..2.5f,
steps = 14, steps = 14,
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(1f)
) )
} Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End)
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")
} }
} }
} }
@ -485,7 +563,7 @@ fun FontSelectionSheetContent(
items(customFonts) { fontEntity -> items(customFonts) { fontEntity ->
val isSelected = currentCustomFontPath == fontEntity.path val isSelected = currentCustomFontPath == fontEntity.path
val fontFamily = remember(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( ListItem(

View file

@ -18,7 +18,7 @@
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// PdfViewerScreen.kt // PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") @file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable")
package com.aryan.reader.pdf package com.aryan.reader.pdf
@ -27,6 +27,9 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager 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 android.graphics.Bitmap
import kotlin.math.max import kotlin.math.max
import android.graphics.RectF import android.graphics.RectF
@ -4286,6 +4289,10 @@ fun PdfViewerScreen(
var leftPulseTrigger by remember { mutableLongStateOf(0L) } var leftPulseTrigger by remember { mutableLongStateOf(0L) }
var rightPulseTrigger 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( val leftPulseAlpha by animateFloatAsState(
targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f, targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f,
animationSpec = tween(150), label = "leftPulse" animationSpec = tween(150), label = "leftPulse"
@ -4302,6 +4309,7 @@ fun PdfViewerScreen(
val scrollAmount = boxMaxHeightFloat * 0.75f val scrollAmount = boxMaxHeightFloat * 0.75f
// Left Region
Box( Box(
modifier = regionWidth modifier = regionWidth
.then(regionHeight) .then(regionHeight)
@ -4310,22 +4318,65 @@ fun PdfViewerScreen(
.padding(start = 8.dp) .padding(start = 8.dp)
.background(Color.White.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) .background(Color.White.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp))
.border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable( .pointerInput(Unit) {
interactionSource = remember { MutableInteractionSource() }, awaitEachGesture {
indication = null, val down = awaitFirstDown()
onClick = { 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") Timber.tag("MusicianMode").d("Left region tapped")
leftPulseTrigger = System.currentTimeMillis() leftPulseTrigger = System.currentTimeMillis()
triggerAutoScrollTempPause(600L) triggerAutoScrollTempPause(600L)
coroutineScope.launch { coroutineScope.launch {
verticalReaderState.scrollBy(-scrollAmount) 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( Box(
modifier = regionWidth modifier = regionWidth
.then(regionHeight) .then(regionHeight)
@ -4334,24 +4385,65 @@ fun PdfViewerScreen(
.padding(end = 8.dp) .padding(end = 8.dp)
.background(Color.White.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) .background(Color.White.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp))
.border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.clickable( .pointerInput(totalPages) {
interactionSource = remember { MutableInteractionSource() }, awaitEachGesture {
indication = null, val down = awaitFirstDown()
onClick = { 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") Timber.tag("MusicianMode").d("Right region tapped")
rightPulseTrigger = System.currentTimeMillis() // Trigger flash rightPulseTrigger = System.currentTimeMillis()
// Pause loop to allow smooth scroll
triggerAutoScrollTempPause(600L) triggerAutoScrollTempPause(600L)
coroutineScope.launch { coroutineScope.launch {
verticalReaderState.scrollBy(scrollAmount) 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 // OCR language download indicator
AnimatedVisibility( AnimatedVisibility(