diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 0a39698..f896b5a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -2148,6 +2148,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isRecent: Boolean, sourceFolderUri: String? = null ) = withContext(Dispatchers.IO) { + val addStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}") val isNewBook = withContext(Dispatchers.IO) { recentFilesRepository.getFileByBookId(bookId) == null } @@ -2165,6 +2167,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") + Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") + val parseStart = System.currentTimeMillis() try { importMutex.withLock { bookForMetadata = withContext(Dispatchers.IO) { @@ -2188,13 +2192,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio singleFileImporter.importSingleFile( inputStream, type, - originalBookNameHint = displayName + originalBookNameHint = displayName, + bookId = bookId ) } } } } } + Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms") } catch (e: Exception) { Timber.e( e, @@ -2202,6 +2208,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) bookForMetadata = null } + Timber.tag("FileOpenPerf").d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms") } val finalBookMetadata = bookForMetadata @@ -2389,7 +2396,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun openBook( uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null ) { - Timber.d("Opening book type: $type for bookId: $bookId") + val openBookStartTime = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") + + try { + val cursor = appContext.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L + val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown" + Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") + } + } + } catch (e: Exception) { + Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details") + } viewModelScope.launch { _internalState.update { @@ -2416,7 +2439,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - Timber.d("openBook: Loading PDF. bookId=$bookId ...") + Timber.tag("FileOpenPerf").d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") _internalState.update { it.copy( selectedPdfUri = uri, @@ -2442,6 +2465,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio recentFilesRepository.syncLocalMetadataToFolder(bookId) } } + Timber.tag("FileOpenPerf").d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") val locator = if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { Locator( @@ -2486,6 +2510,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun loadSingleFile(uri: Uri, bookId: String, type: FileType, customDisplayName: String? = null) { + val loadStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } @@ -2497,19 +2523,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (inputStream == null) { throw Exception("Could not open input stream for URI") } - // singleFileImporter is now actually an instance of SingleFileImporter (see below) - // You will need to rename the class inside SingleFileImporter.kt singleFileImporter.importSingleFile( inputStream, type, originalBookNameHint = customDisplayName ?: getFileNameFromUri( uri, appContext - ) ?: "unknown_doc" + ) ?: "unknown_doc", + bookId = bookId ) } } + Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.i("Import successful ($type). Title: ${epubBook.title}") addFileToRecent( uri, @@ -2522,6 +2548,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } + Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") } catch (e: Exception) { Timber.e(e, "Error parsing file ($type) for URI: $uri") _internalState.update { @@ -2620,6 +2647,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null) { + val loadStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } @@ -2641,6 +2670,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } Timber.i("EPUB parsing successful. Title: ${epubBook.title}") + Timber.tag("FileOpenPerf").d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( uri, @@ -2653,6 +2683,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } + Timber.tag("FileOpenPerf").d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") } catch (e: Exception) { Timber.e(e, "Error parsing EPUB for URI: $uri") _internalState.update { diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 00a2833..2617344 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -30,39 +30,65 @@ import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.util.data.MutableDataSet import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.jsoup.Jsoup import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.InputStream -import java.util.UUID class SingleFileImporter(private val context: Context) { + private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true } + suspend fun importSingleFile( inputStream: InputStream, type: FileType, - originalBookNameHint: String + originalBookNameHint: String, + bookId: String ): EpubBook { return when (type) { - FileType.MD -> parseMarkdown(inputStream, originalBookNameHint) - FileType.TXT -> parsePlainText(inputStream, originalBookNameHint) - FileType.HTML -> parseHtml(inputStream, originalBookNameHint) - else -> parsePlainText(inputStream, originalBookNameHint) // Fallback + FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId) + FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId) + FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId) + else -> parsePlainText(inputStream, originalBookNameHint, bookId) // Fallback } } private suspend fun parseMarkdown( inputStream: InputStream, - originalBookNameHint: String + originalBookNameHint: String, + bookId: String ): EpubBook = withContext(Dispatchers.IO) { + val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { + if (!exists()) mkdirs() + } + val metadataFile = File(extractionDir, "book_metadata.json") + + if (metadataFile.exists()) { + try { + val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook + } catch (e: Exception) { + Timber.e(e, "Failed to load cached MD, parsing again") + } + } + + val parseStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint") Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint") val title = originalBookNameHint.substringBeforeLast(".") // Read the full Markdown content val markdownContent = inputStream.bufferedReader().use { it.readText() } + Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Read ${markdownContent.length} chars | elapsed=${System.currentTimeMillis() - parseStart}ms") + // Flexmark Setup val options = MutableDataSet().apply { set(Parser.EXTENSIONS, listOf( @@ -95,55 +121,53 @@ class SingleFileImporter(private val context: Context) { markdownContent.split("\n\n---\n\n") } - val bookId = UUID.randomUUID().toString() - val extractionDir = File(context.cacheDir, "imported_md_$bookId").apply { - if (!exists()) mkdirs() - } + Timber.tag("FileOpenPerf").d("[MD] parseMarkdown: Split into ${rawChapters.size} raw chapters | elapsed=${System.currentTimeMillis() - parseStart}ms") - val chapters = mutableListOf() + val chapters = rawChapters.mapIndexed { index, rawText -> + async(Dispatchers.Default) { + if (rawText.isBlank()) return@async null - rawChapters.forEachIndexed { index, rawText -> - if (rawText.isBlank()) return@forEachIndexed + val pageNum = index + 1 + val chapterTitle = "Page $pageNum" - val pageNum = index + 1 - val chapterTitle = "Page $pageNum" + val document = parser.parse(rawText) + val htmlBody = renderer.render(document) - val document = parser.parse(rawText) - val htmlBody = renderer.render(document) + val fileName = "page_$pageNum.html" + val file = File(extractionDir, fileName) - val fileName = "page_$pageNum.html" - val file = File(extractionDir, fileName) + val fullHtml = """ + + + + $chapterTitle + + + + $htmlBody + + + """.trimIndent() - val fullHtml = """ - - - - $chapterTitle - - - - $htmlBody - - - """.trimIndent() + file.writeText(fullHtml) - file.writeText(fullHtml) - - chapters.add(EpubChapter( - chapterId = "${bookId}_$pageNum", - absPath = fileName, - title = chapterTitle, - htmlFilePath = fileName, - plainTextContent = Jsoup.parse(htmlBody).text(), - htmlContent = "", - depth = 0, - isInToc = true - )) - } + EpubChapter( + chapterId = "${bookId}_$pageNum", + absPath = fileName, + title = chapterTitle, + htmlFilePath = fileName, + plainTextContent = Jsoup.parse(htmlBody).text(), + htmlContent = "", + depth = 0, + isInToc = true + ) + } + }.awaitAll().filterNotNull() Timber.d("Markdown import complete. Created ${chapters.size} chapters (one per page).") + Timber.tag("FileOpenPerf").d("[MD] parseMarkdown COMPLETE | chapters=${chapters.size} | totalElapsed=${System.currentTimeMillis() - parseStart}ms") - return@withContext EpubBook( + val book = EpubBook( fileName = originalBookNameHint, title = title, author = "Unknown", @@ -156,19 +180,40 @@ class SingleFileImporter(private val context: Context) { extractionBasePath = extractionDir.absolutePath, css = emptyMap() ) + + try { + metadataFile.writeText(jsonSerializer.encodeToString(book)) + } catch (e: Exception) { + Timber.e(e, "Failed to cache MD metadata") + } + + return@withContext book } private suspend fun parsePlainText( inputStream: InputStream, - originalBookNameHint: String + originalBookNameHint: String, + bookId: String ): EpubBook = withContext(Dispatchers.IO) { - Timber.d("Parsing Plain Text with Virtual Chaptering: $originalBookNameHint") - val title = originalBookNameHint.substringBeforeLast(".") - val bookId = UUID.randomUUID().toString() - - val extractionDir = File(context.cacheDir, "imported_txt_$bookId").apply { + val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { if (!exists()) mkdirs() } + val metadataFile = File(extractionDir, "book_metadata.json") + + if (metadataFile.exists()) { + try { + val cachedBook = jsonSerializer.decodeFromString(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() var chapterCounter = 1 @@ -276,7 +321,9 @@ class SingleFileImporter(private val context: Context) { Timber.d("Imported TXT split into ${chapters.size} chapters.") - return@withContext EpubBook( + Timber.tag("FileOpenPerf").d("[TXT] parsePlainText COMPLETE | chapters=${chapters.size} | totalElapsed=${System.currentTimeMillis() - parseStart}ms") + + val book = EpubBook( fileName = originalBookNameHint, title = title, author = "Unknown", @@ -289,24 +336,52 @@ class SingleFileImporter(private val context: Context) { extractionBasePath = extractionDir.absolutePath, css = emptyMap() ) + + try { + metadataFile.writeText(jsonSerializer.encodeToString(book)) + } catch (e: Exception) { + Timber.e(e, "Failed to cache TXT metadata") + } + + return@withContext book } private suspend fun parseHtml( inputStream: InputStream, - originalBookNameHint: String + originalBookNameHint: String, + bookId: String ): EpubBook = withContext(Dispatchers.IO) { + val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { + if (!exists()) mkdirs() + } + val metadataFile = File(extractionDir, "book_metadata.json") + + if (metadataFile.exists()) { + try { + val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId") + return@withContext cachedBook + } catch (e: Exception) { + Timber.e(e, "Failed to load cached HTML, parsing again") + } + } + + val parseStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint") Timber.d("Importing HTML: $originalBookNameHint") val content = inputStream.bufferedReader().use { it.readText() } val doc = Jsoup.parse(content) val title = doc.title().takeIf { it.isNotBlank() } ?: originalBookNameHint.substringBeforeLast(".") + Timber.tag("FileOpenPerf").d("[HTML] parseHtml: Read ${content.length} chars | elapsed=${System.currentTimeMillis() - parseStart}ms") val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() } ?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() } val finalHtml = doc.outerHtml() - createBookFromHtmlBody(title, null, null, originalBookNameHint, preGeneratedFullHtml = finalHtml, author = author) + Timber.tag("FileOpenPerf").d("[HTML] parseHtml COMPLETE | elapsed=${System.currentTimeMillis() - parseStart}ms") + createBookFromHtmlBody(title, null, null, originalBookNameHint, bookId, extractionDir, metadataFile, preGeneratedFullHtml = finalHtml, author = author) } private fun createBookFromHtmlBody( @@ -314,6 +389,9 @@ class SingleFileImporter(private val context: Context) { @Suppress("SameParameterValue") bodyContent: String?, @Suppress("SameParameterValue")cssStyle: String?, fileName: String, + bookId: String, + extractionDir: File, + metadataFile: File, preGeneratedFullHtml: String? = null, author: String? = null ): EpubBook { @@ -334,11 +412,6 @@ class SingleFileImporter(private val context: Context) { val plainText = Jsoup.parse(fullHtml).text() - val bookId = UUID.randomUUID().toString() - val extractionDir = File(context.cacheDir, "single_file_cache_$bookId").apply { - if (!exists()) mkdirs() - } - try { File(extractionDir, "content.html").writeText(fullHtml) } catch (e: Exception) { @@ -356,7 +429,7 @@ class SingleFileImporter(private val context: Context) { isInToc = true ) - return EpubBook( + val book = EpubBook( fileName = fileName, title = title, author = author ?: "", @@ -369,5 +442,13 @@ class SingleFileImporter(private val context: Context) { extractionBasePath = extractionDir.absolutePath, css = emptyMap() ) + + try { + metadataFile.writeText(jsonSerializer.encodeToString(book)) + } catch (e: Exception) { + Timber.e(e, "Failed to cache HTML metadata") + } + + return book } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt index 1c4bc4c..46df7fe 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt @@ -37,7 +37,7 @@ data class ChapterLoadingResult( /** * loads the chapter HTML, splits it into chunks, and calculates - * the initial chunk to display based on navigation state (CFI, overrides, etc). + * the initial chunk to display based on navigation state (CFI, overrides, etc.). */ suspend fun loadChapterContent( epubBook: EpubBook, @@ -47,10 +47,10 @@ suspend fun loadChapterContent( cfiToLoad: String?, locatorConverter: LocatorConverter ): ChapterLoadingResult = withContext(Dispatchers.IO) { - val chapter = epubBook.chapters.getOrNull(chapterIndex) - if (chapter == null) { - return@withContext ChapterLoadingResult("", emptyList(), 0, false, "Chapter index out of bounds") - } + val chapter = + epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult( + "", emptyList(), 0, false, "Chapter index out of bounds" + ) try { val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" @@ -60,11 +60,9 @@ suspend fun loadChapterContent( val doc = Jsoup.parse(htmlFile, "UTF-8") val head = doc.head().html() val bodyChildren = doc.body().children().toList() - // Split into chunks of 20 elements val chunkedList = bodyChildren.chunked(20).map { chunkOfElements -> chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } } - // Fallback for empty chapters if (chunkedList.isEmpty()) { head to listOf("

This chapter is empty.

") } else { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 5ffb17e..a52c9a4 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -18,7 +18,9 @@ * mail: epistemereader@gmail.com */ // EpubReaderScreen.kt -@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead") +@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead", + "UnusedVariable", "Unused" +) package com.aryan.reader.epubreader @@ -29,6 +31,9 @@ import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.media.AudioManager +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import android.net.Uri import android.os.Build import android.webkit.WebView @@ -50,7 +55,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -65,6 +69,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsEndWidth @@ -72,6 +77,8 @@ import androidx.compose.foundation.layout.windowInsetsStartWidth import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -112,6 +119,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -285,7 +293,7 @@ private const val PREF_USE_ONLINE_DICT = "use_online_dictionary" private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" private fun loadUseOnlineDict(context: Context): Boolean { - if (BuildConfig.FLAVOR == "oss") return false + @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(PREF_USE_ONLINE_DICT, true) } @@ -403,7 +411,6 @@ fun EpubReaderHost( var showJustifyWarningDialog by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) } - var currentTextAlign by remember { mutableStateOf(loadTextAlign(context)) } var chunkTargetOverride by remember { mutableStateOf(null) } @@ -560,7 +567,7 @@ fun EpubReaderHost( var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } - val onDictionaryLookup = { word: String -> + @Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String -> val isOss = BuildConfig.FLAVOR == "oss" val effectiveUseOnline = !isOss && useOnlineDictionary @@ -759,13 +766,17 @@ fun EpubReaderHost( } } - var currentFontSizeEm by remember { mutableFloatStateOf(loadFontSize(context)) } - var currentLineHeight by remember { mutableFloatStateOf(loadLineHeight(context)) } var showFormatAdjustmentBars by remember { mutableStateOf(false) } - val (initialFont, initialCustomPath) = remember { loadFontSelection(context) } - var currentFontFamily by remember { mutableStateOf(initialFont) } - var currentCustomFontPath by remember { mutableStateOf(initialCustomPath) } + var isFormatLocal by remember { mutableStateOf(loadFormatIsLocal(context, bookId)) } + val initialFormatSettings = remember(isFormatLocal) { loadFormatSettings(context, bookId, isFormatLocal) } + + var currentFontSizeEm by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.fontSize) } + var currentLineHeight by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.lineHeight) } + var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) } + var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) } + var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) } + val activeFontFamily = remember(currentFontFamily, currentCustomFontPath) { getComposeFontFamily( font = currentFontFamily, @@ -777,15 +788,16 @@ fun EpubReaderHost( var showFontSelectionSheet by remember { mutableStateOf(false) } val fontSheetState = rememberModalBottomSheetState() - LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign) { - saveReaderSettings( - context, - currentFontSizeEm, - currentLineHeight, - currentFontFamily, - currentCustomFontPath, - currentTextAlign - ) + LaunchedEffect(currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) { + if (isFormatLocal) { + saveLocalReaderSettings( + context, bookId, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign + ) + } else { + saveReaderSettings( + context, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign + ) + } } LaunchedEffect(bannerMessage) { @@ -2842,10 +2854,12 @@ fun EpubReaderHost( if (isMusicianMode && isAutoScrollModeActive) { val density = LocalDensity.current - // States for visual feedback var leftPulseTrigger by remember { mutableLongStateOf(0L) } var rightPulseTrigger by remember { mutableLongStateOf(0L) } + var leftHoldProgress by remember { mutableFloatStateOf(0f) } + var rightHoldProgress by remember { mutableFloatStateOf(0f) } + val leftPulseAlpha by animateFloatAsState( targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f, animationSpec = tween(150), label = "leftPulse" @@ -2867,24 +2881,67 @@ fun EpubReaderHost( .align(Alignment.TopStart) .offset(y = topOffset) .padding(start = 8.dp) - .background(MaterialTheme.colorScheme.primary.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) // Pulse + .background(MaterialTheme.colorScheme.primary.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - leftPulseTrigger = System.currentTimeMillis() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + var isLongPress = false + val job = scope.launch { + val startTime = System.currentTimeMillis() + while (isActive) { + val elapsed = System.currentTimeMillis() - startTime + if (elapsed >= 1000) { + leftHoldProgress = 0f + isLongPress = true + leftPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(1000L) - // Pause loop to allow smooth scroll - triggerAutoScrollTempPause(600L) + scope.launch { + webViewRefForTts?.evaluateJavascript( + "window.scrollTo({ top: 0, behavior: 'auto' });", null + ) + } + break + } + leftHoldProgress = elapsed / 1000f + delay(16) + } + } - val amount = (currentClientHeightValue * 0.75f).toInt() - webViewRefForTts?.evaluateJavascript( - "window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null - ) + val up = waitForUpOrCancellation() + job.cancel() + leftHoldProgress = 0f + + if (!isLongPress && up != null) { + up.consume() + leftPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(600L) + val amount = (currentClientHeightValue * 0.75f).toInt() + webViewRefForTts?.evaluateJavascript( + "window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null + ) + } } + }, + contentAlignment = Alignment.Center + ) { + if (leftHoldProgress > 0f) { + CircularProgressIndicator( + progress = { leftHoldProgress }, + modifier = Modifier.size(48.dp).alpha(0.6f), + color = MaterialTheme.colorScheme.onSurface, + trackColor = Color.Transparent, + strokeWidth = 4.dp ) - ) + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(24.dp).alpha(0.6f), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } // Right Region Box( @@ -2893,23 +2950,67 @@ fun EpubReaderHost( .align(Alignment.TopEnd) .offset(y = topOffset) .padding(end = 8.dp) - .background(MaterialTheme.colorScheme.primary.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) // Pulse + .background(MaterialTheme.colorScheme.primary.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - rightPulseTrigger = System.currentTimeMillis() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + var isLongPress = false + val job = scope.launch { + val startTime = System.currentTimeMillis() + while (isActive) { + val elapsed = System.currentTimeMillis() - startTime + if (elapsed >= 1000) { + rightHoldProgress = 0f + isLongPress = true + rightPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(1000L) - triggerAutoScrollTempPause(600L) + scope.launch { + webViewRefForTts?.evaluateJavascript( + "window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' });", null + ) + } + break + } + rightHoldProgress = elapsed / 1000f + delay(16) + } + } - val amount = (currentClientHeightValue * 0.75f).toInt() - webViewRefForTts?.evaluateJavascript( - "window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null - ) + val up = waitForUpOrCancellation() + job.cancel() + rightHoldProgress = 0f + + if (!isLongPress && up != null) { + up.consume() + rightPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(600L) + val amount = (currentClientHeightValue * 0.75f).toInt() + webViewRefForTts?.evaluateJavascript( + "window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null + ) + } } + }, + contentAlignment = Alignment.Center + ) { + if (rightHoldProgress > 0f) { + CircularProgressIndicator( + progress = { rightHoldProgress }, + modifier = Modifier.size(48.dp).alpha(0.6f), + color = MaterialTheme.colorScheme.onSurface, + trackColor = Color.Transparent, + strokeWidth = 4.dp ) - ) + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(24.dp).alpha(0.6f), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } } } @@ -3006,11 +3107,7 @@ fun EpubReaderHost( onStartAutoScroll = { isAutoScrollModeActive = true isAutoScrollPlaying = true - showBars = if (isMusicianMode) { - false - } else { - true - } + showBars = !isMusicianMode }, searchFocusRequester = searchFocusRequester, modifier = Modifier.align(Alignment.TopCenter), @@ -3306,8 +3403,16 @@ fun EpubReaderHost( currentCustomFontPath = null currentTextAlign = ReaderTextAlign.DEFAULT }, - modifier = Modifier.align(Alignment.BottomCenter) - .padding(bottom = bottomPadding) + isLocalMode = isFormatLocal, + onLocalModeToggle = { + isFormatLocal = it + saveFormatIsLocal(context, bookId, it) + }, + onClose = { showFormatAdjustmentBars = false }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomPadding + 16.dp) + .padding(horizontal = 16.dp) ) EpubReaderAiOverlays( diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index bbf4f2d..5c746af 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -29,7 +29,12 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,19 +42,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -84,6 +86,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.edit import com.aryan.reader.R import com.aryan.reader.data.CustomFontEntity @@ -116,6 +119,89 @@ enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify") } +data class FormatSettings( + val fontSize: Float, + val lineHeight: Float, + val font: ReaderFont, + val customPath: String?, + val textAlign: ReaderTextAlign +) + +private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_" +private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_" +private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_" +private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_" +private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_" + +fun loadFormatIsLocal(context: Context, bookId: String): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, false) +} + +fun saveFormatIsLocal(context: Context, bookId: String, isLocal: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, isLocal) } +} + +fun saveLocalReaderSettings( + context: Context, + bookId: String, + fontSize: Float, + lineHeight: Float, + fontFamily: ReaderFont, + customFontPath: String?, + textAlign: ReaderTextAlign +) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { + putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize) + putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight) + if (customFontPath != null) { + putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath") + } else { + putString(LOCAL_FONT_FAMILY_PREFIX + bookId, fontFamily.id) + } + putString(LOCAL_TEXT_ALIGN_PREFIX + bookId, textAlign.id) + } +} + +fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + + val fontSize = if (isLocal && prefs.contains(LOCAL_FONT_SIZE_PREFIX + bookId)) { + prefs.getFloat(LOCAL_FONT_SIZE_PREFIX + bookId, DEFAULT_FONT_SIZE_VAL) + } else { + prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL) + } + + val lineHeight = if (isLocal && prefs.contains(LOCAL_LINE_HEIGHT_PREFIX + bookId)) { + prefs.getFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, DEFAULT_LINE_HEIGHT_VAL) + } else { + prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL) + } + + val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) { + prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id + } else { + prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id + } + + val (font, customPath) = if (savedFontVal.startsWith("custom|")) { + Pair(ReaderFont.ORIGINAL, savedFontVal.substringAfter("custom|")) + } else { + Pair(ReaderFont.entries.find { it.id == savedFontVal } ?: ReaderFont.ORIGINAL, null) + } + + val alignId = if (isLocal && prefs.contains(LOCAL_TEXT_ALIGN_PREFIX + bookId)) { + prefs.getString(LOCAL_TEXT_ALIGN_PREFIX + bookId, ReaderTextAlign.DEFAULT.id) + } else { + prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id) + } + val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT + + return FormatSettings(fontSize, lineHeight, font, customPath, textAlign) +} + fun getComposeFontFamily( font: ReaderFont, customFontPath: String? = null, @@ -147,19 +233,6 @@ fun getComposeFontFamily( return FontFamily.Default } -fun loadFontSelection(context: Context): Pair { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val savedVal = prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id - - return if (savedVal.startsWith("custom|")) { - val path = savedVal.substringAfter("custom|") - Pair(ReaderFont.ORIGINAL, path) - } else { - val font = ReaderFont.entries.find { it.id == savedVal } ?: ReaderFont.ORIGINAL - Pair(font, null) - } -} - fun saveReaderSettings( context: Context, fontSize: Float, @@ -181,22 +254,6 @@ fun saveReaderSettings( } } -fun loadFontSize(context: Context): Float { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL) -} - -fun loadLineHeight(context: Context): Float { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL) -} - -fun loadTextAlign(context: Context): ReaderTextAlign { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - val id = prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id) - return ReaderTextAlign.entries.find { it.id == id } ?: ReaderTextAlign.DEFAULT -} - fun saveAutoScrollSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) } @@ -240,6 +297,9 @@ fun ReaderTextFormatPanel( currentTextAlign: ReaderTextAlign, onTextAlignChange: (ReaderTextAlign) -> Unit, onReset: () -> Unit, + isLocalMode: Boolean, + onLocalModeToggle: (Boolean) -> Unit, + onClose: () -> Unit, modifier: Modifier = Modifier ) { AnimatedVisibility( @@ -249,40 +309,106 @@ fun ReaderTextFormatPanel( modifier = modifier ) { Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - shadowElevation = 8.dp, - modifier = Modifier.fillMaxWidth() + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.95f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)), + modifier = Modifier + .fillMaxWidth() + .animateContentSize() ) { Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + modifier = Modifier.padding(16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Font Family", style = MaterialTheme.typography.labelLarge) + Box { + var showModeMenu by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showModeMenu = true } + .padding(4.dp) + ) { + Text( + text = if (isLocalMode) "Local Format" else "Global Format", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select Mode", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + DropdownMenu(expanded = showModeMenu, onDismissRequest = { showModeMenu = false }) { + DropdownMenuItem( + text = { + Column { + Text("Global Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text("Applies to all files", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + onClick = { onLocalModeToggle(false); showModeMenu = false }, + trailingIcon = { if (!isLocalMode) Icon(Icons.Default.Check, null) } + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Column { + Text("Local Format", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text("Saved for this file only", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + onClick = { onLocalModeToggle(true); showModeMenu = false }, + trailingIcon = { if (isLocalMode) Icon(Icons.Default.Check, null) } + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onReset, contentPadding = PaddingValues(horizontal = 8.dp)) { + Text("Reset") + } + IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, "Close", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp)) + } + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { Surface( onClick = onFontOptionClick, - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.height(40.dp) + modifier = Modifier + .weight(0.45f) + .height(48.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(horizontal = 12.dp) ) { - val displayName = currentCustomFontName ?: currentFont.displayName Text( - text = displayName, + text = currentCustomFontName ?: currentFont.displayName, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(Modifier.width(8.dp)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -291,117 +417,69 @@ fun ReaderTextFormatPanel( ) } } - } - HorizontalDivider() - - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .weight(0.55f) + .height(48.dp) ) { - Text("Font Size", style = MaterialTheme.typography.labelLarge) - Text( - "%.1fx".format(currentFontSize), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - Slider( - value = currentFontSize, - onValueChange = onFontSizeChange, - valueRange = 0.5f..3.0f, - steps = 24, - modifier = Modifier.fillMaxWidth() - ) - } - - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Line Spacing", style = MaterialTheme.typography.labelLarge) - Text( - "%.1fx".format(currentLineHeight), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - Slider( - value = currentLineHeight, - onValueChange = onLineHeightChange, - valueRange = 1.0f..2.5f, - steps = 14, - modifier = Modifier.fillMaxWidth() - ) - } - - HorizontalDivider() - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box { - var alignmentMenuExpanded by remember { mutableStateOf(false) } - - Surface( - onClick = { alignmentMenuExpanded = true }, - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(8.dp) - ) { - Icon( - painter = androidx.compose.ui.res.painterResource(id = currentTextAlign.iconResId), - contentDescription = "Text Alignment", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - DropdownMenu( - expanded = alignmentMenuExpanded, - onDismissRequest = { alignmentMenuExpanded = false } - ) { + Row { ReaderTextAlign.entries.forEach { align -> - DropdownMenuItem( - text = { Text(align.displayName) }, - leadingIcon = { - Icon( - painter = androidx.compose.ui.res.painterResource(id = align.iconResId), - contentDescription = null - ) - }, - trailingIcon = { - if (align == currentTextAlign) { - Icon(Icons.Default.Check, contentDescription = "Selected") - } - }, - onClick = { - onTextAlignChange(align) - alignmentMenuExpanded = false - } - ) + val isSelected = currentTextAlign == align + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onTextAlignChange(align) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = align.iconResId), + contentDescription = align.displayName, + tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + Text( + text = align.displayName, + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } + } - TextButton(onClick = onReset) { - Text("Reset Defaults") + Spacer(Modifier.height(16.dp)) + + // Sliders + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp)) + Slider( + value = currentFontSize, + onValueChange = onFontSizeChange, + valueRange = 0.5f..3.0f, + steps = 24, + modifier = Modifier.weight(1f) + ) + Text("%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Spacing", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(55.dp)) + Slider( + value = currentLineHeight, + onValueChange = onLineHeightChange, + valueRange = 1.0f..2.5f, + steps = 14, + modifier = Modifier.weight(1f) + ) + Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(35.dp), textAlign = TextAlign.End) } } } @@ -485,7 +563,7 @@ fun FontSelectionSheetContent( items(customFonts) { fontEntity -> val isSelected = currentCustomFontPath == fontEntity.path val fontFamily = remember(fontEntity.path) { - try { FontFamily(androidx.compose.ui.text.font.Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default } + try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default } } ListItem( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 9bba23a..29db9b7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -18,7 +18,7 @@ * mail: epistemereader@gmail.com */ // PdfViewerScreen.kt -@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable") package com.aryan.reader.pdf @@ -27,6 +27,9 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.pm.PackageManager +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import android.graphics.Bitmap import kotlin.math.max import android.graphics.RectF @@ -4286,6 +4289,10 @@ fun PdfViewerScreen( var leftPulseTrigger by remember { mutableLongStateOf(0L) } var rightPulseTrigger by remember { mutableLongStateOf(0L) } + // --- ADD THESE STATES --- + var leftHoldProgress by remember { mutableFloatStateOf(0f) } + var rightHoldProgress by remember { mutableFloatStateOf(0f) } + val leftPulseAlpha by animateFloatAsState( targetValue = if (System.currentTimeMillis() - leftPulseTrigger < 150) 0.3f else 0f, animationSpec = tween(150), label = "leftPulse" @@ -4302,6 +4309,7 @@ fun PdfViewerScreen( val scrollAmount = boxMaxHeightFloat * 0.75f + // Left Region Box( modifier = regionWidth .then(regionHeight) @@ -4310,22 +4318,65 @@ fun PdfViewerScreen( .padding(start = 8.dp) .background(Color.White.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - Timber.tag("MusicianMode").d("Left region tapped") - leftPulseTrigger = System.currentTimeMillis() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + var isLongPress = false + val job = coroutineScope.launch { + val startTime = System.currentTimeMillis() + while (isActive) { + val elapsed = System.currentTimeMillis() - startTime + if (elapsed >= 1000) { + leftHoldProgress = 0f + isLongPress = true + leftPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(1000L) - triggerAutoScrollTempPause(600L) + coroutineScope.launch { + verticalReaderState.scrollToPage(0) + } + break + } + leftHoldProgress = elapsed / 1000f + delay(16) + } + } - coroutineScope.launch { - verticalReaderState.scrollBy(-scrollAmount) + val up = waitForUpOrCancellation() + job.cancel() + leftHoldProgress = 0f + + if (!isLongPress && up != null) { + up.consume() + Timber.tag("MusicianMode").d("Left region tapped") + leftPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(600L) + coroutineScope.launch { + verticalReaderState.scrollBy(-scrollAmount) + } } } + }, + contentAlignment = Alignment.Center + ) { + if (leftHoldProgress > 0f) { + CircularProgressIndicator( + progress = { leftHoldProgress }, + modifier = Modifier.size(48.dp).alpha(0.6f), + color = MaterialTheme.colorScheme.primary, + trackColor = Color.Transparent, + strokeWidth = 4.dp ) - ) + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(24.dp).alpha(0.6f), + tint = MaterialTheme.colorScheme.primary + ) + } + } + // Right Region Box( modifier = regionWidth .then(regionHeight) @@ -4334,22 +4385,63 @@ fun PdfViewerScreen( .padding(end = 8.dp) .background(Color.White.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - Timber.tag("MusicianMode").d("Right region tapped") - rightPulseTrigger = System.currentTimeMillis() // Trigger flash + .pointerInput(totalPages) { + awaitEachGesture { + val down = awaitFirstDown() + var isLongPress = false + val job = coroutineScope.launch { + val startTime = System.currentTimeMillis() + while (isActive) { + val elapsed = System.currentTimeMillis() - startTime + if (elapsed >= 1000) { + rightHoldProgress = 0f + isLongPress = true + rightPulseTrigger = System.currentTimeMillis() + triggerAutoScrollTempPause(1000L) - // Pause loop to allow smooth scroll - triggerAutoScrollTempPause(600L) + coroutineScope.launch { + verticalReaderState.scrollToPage(totalPages - 1) + } + break + } + rightHoldProgress = elapsed / 1000f + delay(16) + } + } - coroutineScope.launch { - verticalReaderState.scrollBy(scrollAmount) + val up = waitForUpOrCancellation() + 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 + ) + } + } } }