General improvements (#50)
* Implemented long-press gestures(on the left and right region) for rapid scrolling(to top or bottom) in Musician Mode for both EPUB and PDF readers. * Added support for book-specific reader settings and redesigned the format adjustment UI. * perf: implement persistent caching and parallel parsing for single-file imports - Replaced random UUID generation with bookId-based pathing in SingleFileImporter to enable cache reuse. - Added a JSON metadata fast-path to return EpubBook objects instantly on subsequent opens for MD, TXT, and HTML. - Parallelized Markdown chapter parsing using Coroutines (async/awaitAll) to leverage multicore CPUs.
This commit is contained in:
parent
ef1bfdc57a
commit
acf282d4c7
6 changed files with 684 additions and 299 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,55 +121,53 @@ 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) {
|
||||||
|
if (rawText.isBlank()) return@async null
|
||||||
|
|
||||||
rawChapters.forEachIndexed { index, rawText ->
|
val pageNum = index + 1
|
||||||
if (rawText.isBlank()) return@forEachIndexed
|
val chapterTitle = "Page $pageNum"
|
||||||
|
|
||||||
val pageNum = index + 1
|
val document = parser.parse(rawText)
|
||||||
val chapterTitle = "Page $pageNum"
|
val htmlBody = renderer.render(document)
|
||||||
|
|
||||||
val document = parser.parse(rawText)
|
val fileName = "page_$pageNum.html"
|
||||||
val htmlBody = renderer.render(document)
|
val file = File(extractionDir, fileName)
|
||||||
|
|
||||||
val fileName = "page_$pageNum.html"
|
val fullHtml = """
|
||||||
val file = File(extractionDir, fileName)
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>$chapterTitle</title>
|
||||||
|
<style>$style</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$htmlBody
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
val fullHtml = """
|
file.writeText(fullHtml)
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>$chapterTitle</title>
|
|
||||||
<style>$style</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
$htmlBody
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
file.writeText(fullHtml)
|
EpubChapter(
|
||||||
|
chapterId = "${bookId}_$pageNum",
|
||||||
chapters.add(EpubChapter(
|
absPath = fileName,
|
||||||
chapterId = "${bookId}_$pageNum",
|
title = chapterTitle,
|
||||||
absPath = fileName,
|
htmlFilePath = fileName,
|
||||||
title = chapterTitle,
|
plainTextContent = Jsoup.parse(htmlBody).text(),
|
||||||
htmlFilePath = fileName,
|
htmlContent = "",
|
||||||
plainTextContent = Jsoup.parse(htmlBody).text(),
|
depth = 0,
|
||||||
htmlContent = "",
|
isInToc = true
|
||||||
depth = 0,
|
)
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
} else {
|
||||||
currentCustomFontPath,
|
saveReaderSettings(
|
||||||
currentTextAlign
|
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
|
||||||
leftPulseTrigger = System.currentTimeMillis()
|
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 {
|
||||||
triggerAutoScrollTempPause(600L)
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"window.scrollTo({ top: 0, behavior: 'auto' });", null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
leftHoldProgress = elapsed / 1000f
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val amount = (currentClientHeightValue * 0.75f).toInt()
|
val up = waitForUpOrCancellation()
|
||||||
webViewRefForTts?.evaluateJavascript(
|
job.cancel()
|
||||||
"window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null
|
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
|
// Right Region
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -2893,23 +2950,67 @@ 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
|
||||||
rightPulseTrigger = System.currentTimeMillis()
|
val job = scope.launch {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
while (isActive) {
|
||||||
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
rightHoldProgress = 0f
|
||||||
|
isLongPress = true
|
||||||
|
rightPulseTrigger = System.currentTimeMillis()
|
||||||
|
triggerAutoScrollTempPause(1000L)
|
||||||
|
|
||||||
triggerAutoScrollTempPause(600L)
|
scope.launch {
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' });", null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rightHoldProgress = elapsed / 1000f
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val amount = (currentClientHeightValue * 0.75f).toInt()
|
val up = waitForUpOrCancellation()
|
||||||
webViewRefForTts?.evaluateJavascript(
|
job.cancel()
|
||||||
"window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null
|
rightHoldProgress = 0f
|
||||||
)
|
|
||||||
|
if (!isLongPress && up != null) {
|
||||||
|
up.consume()
|
||||||
|
rightPulseTrigger = System.currentTimeMillis()
|
||||||
|
triggerAutoScrollTempPause(600L)
|
||||||
|
val amount = (currentClientHeightValue * 0.75f).toInt()
|
||||||
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
"window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (rightHoldProgress > 0f) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { rightHoldProgress },
|
||||||
|
modifier = Modifier.size(48.dp).alpha(0.6f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
trackColor = Color.Transparent,
|
||||||
|
strokeWidth = 4.dp
|
||||||
)
|
)
|
||||||
)
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDownward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp).alpha(0.6f),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3006,11 +3107,7 @@ fun EpubReaderHost(
|
||||||
onStartAutoScroll = {
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
Text(
|
|
||||||
"%.1fx".format(currentFontSize),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Slider(
|
|
||||||
value = currentFontSize,
|
|
||||||
onValueChange = onFontSizeChange,
|
|
||||||
valueRange = 0.5f..3.0f,
|
|
||||||
steps = 24,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text("Line Spacing", style = MaterialTheme.typography.labelLarge)
|
|
||||||
Text(
|
|
||||||
"%.1fx".format(currentLineHeight),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Slider(
|
|
||||||
value = currentLineHeight,
|
|
||||||
onValueChange = onLineHeightChange,
|
|
||||||
valueRange = 1.0f..2.5f,
|
|
||||||
steps = 14,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
var alignmentMenuExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
onClick = { alignmentMenuExpanded = true },
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = androidx.compose.ui.res.painterResource(id = currentTextAlign.iconResId),
|
|
||||||
contentDescription = "Text Alignment",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = alignmentMenuExpanded,
|
|
||||||
onDismissRequest = { alignmentMenuExpanded = false }
|
|
||||||
) {
|
|
||||||
ReaderTextAlign.entries.forEach { align ->
|
ReaderTextAlign.entries.forEach { align ->
|
||||||
DropdownMenuItem(
|
val isSelected = currentTextAlign == align
|
||||||
text = { Text(align.displayName) },
|
Column(
|
||||||
leadingIcon = {
|
modifier = Modifier
|
||||||
Icon(
|
.fillMaxHeight()
|
||||||
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
|
.weight(1f)
|
||||||
contentDescription = null
|
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
)
|
.clickable { onTextAlignChange(align) },
|
||||||
},
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
trailingIcon = {
|
verticalArrangement = Arrangement.Center
|
||||||
if (align == currentTextAlign) {
|
) {
|
||||||
Icon(Icons.Default.Check, contentDescription = "Selected")
|
Icon(
|
||||||
}
|
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
|
||||||
},
|
contentDescription = align.displayName,
|
||||||
onClick = {
|
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onTextAlignChange(align)
|
modifier = Modifier.size(18.dp)
|
||||||
alignmentMenuExpanded = false
|
)
|
||||||
}
|
Text(
|
||||||
)
|
text = align.displayName,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextButton(onClick = onReset) {
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Reset Defaults")
|
|
||||||
|
// Sliders
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp))
|
||||||
|
Slider(
|
||||||
|
value = currentFontSize,
|
||||||
|
onValueChange = onFontSizeChange,
|
||||||
|
valueRange = 0.5f..3.0f,
|
||||||
|
steps = 24,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text("%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Spacing", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp))
|
||||||
|
Slider(
|
||||||
|
value = currentLineHeight,
|
||||||
|
onValueChange = onLineHeightChange,
|
||||||
|
valueRange = 1.0f..2.5f,
|
||||||
|
steps = 14,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -485,7 +563,7 @@ fun FontSelectionSheetContent(
|
||||||
items(customFonts) { fontEntity ->
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Timber.tag("MusicianMode").d("Left region tapped")
|
val job = coroutineScope.launch {
|
||||||
leftPulseTrigger = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
|
while (isActive) {
|
||||||
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
leftHoldProgress = 0f
|
||||||
|
isLongPress = true
|
||||||
|
leftPulseTrigger = System.currentTimeMillis()
|
||||||
|
triggerAutoScrollTempPause(1000L)
|
||||||
|
|
||||||
triggerAutoScrollTempPause(600L)
|
coroutineScope.launch {
|
||||||
|
verticalReaderState.scrollToPage(0)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
leftHoldProgress = elapsed / 1000f
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
coroutineScope.launch {
|
val up = waitForUpOrCancellation()
|
||||||
verticalReaderState.scrollBy(-scrollAmount)
|
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(
|
Box(
|
||||||
modifier = regionWidth
|
modifier = regionWidth
|
||||||
.then(regionHeight)
|
.then(regionHeight)
|
||||||
|
|
@ -4334,22 +4385,63 @@ 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
|
||||||
Timber.tag("MusicianMode").d("Right region tapped")
|
val job = coroutineScope.launch {
|
||||||
rightPulseTrigger = System.currentTimeMillis() // Trigger flash
|
val startTime = System.currentTimeMillis()
|
||||||
|
while (isActive) {
|
||||||
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
rightHoldProgress = 0f
|
||||||
|
isLongPress = true
|
||||||
|
rightPulseTrigger = System.currentTimeMillis()
|
||||||
|
triggerAutoScrollTempPause(1000L)
|
||||||
|
|
||||||
// Pause loop to allow smooth scroll
|
coroutineScope.launch {
|
||||||
triggerAutoScrollTempPause(600L)
|
verticalReaderState.scrollToPage(totalPages - 1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rightHoldProgress = elapsed / 1000f
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
coroutineScope.launch {
|
val up = waitForUpOrCancellation()
|
||||||
verticalReaderState.scrollBy(scrollAmount)
|
job.cancel()
|
||||||
|
rightHoldProgress = 0f
|
||||||
|
|
||||||
|
if (!isLongPress && up != null) {
|
||||||
|
up.consume()
|
||||||
|
Timber.tag("MusicianMode").d("Right region tapped")
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue