/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ // EpubReaderScreen.kt @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead", "UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions" ) package com.aryan.reader.epubreader import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.media.AudioManager import android.net.Uri import android.os.Build import android.view.RoundedCorner import android.view.View import android.webkit.WebView import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues 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.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 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 import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.util.UnstableApi import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig import com.aryan.reader.BuiltInThemes import com.aryan.reader.MainViewModel import com.aryan.reader.R import com.aryan.reader.ReaderBrightnessEffect import com.aryan.reader.ReaderFileInfoDialogs import com.aryan.reader.ReaderBrightnessSheet import com.aryan.reader.ReaderScreenOrientationEffect import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderThemePanel import com.aryan.reader.RenderMode import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsWordReplacementsSheet import com.aryan.reader.areReaderAiFeaturesEnabled import com.aryan.reader.countWords import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency import com.aryan.reader.loadReaderBrightnessSettings import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadEpubRightToLeftPagination import com.aryan.reader.loadReaderThemeId import com.aryan.reader.loadReaderSliderToggled import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.readerSliderBookmarkPosition import com.aryan.reader.readerSliderChromeColors import com.aryan.reader.readerSliderToggleState import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.ListItemBlock import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.LocatorConverter import com.aryan.reader.paginatedreader.PaginatedReaderScreen import com.aryan.reader.paginatedreader.ParagraphBlock import com.aryan.reader.paginatedreader.QuoteBlock import com.aryan.reader.paginatedreader.TextContentBlock import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency import com.aryan.reader.saveReaderBrightnessSettings import com.aryan.reader.saveReaderScreenOrientationMode import com.aryan.reader.saveEpubRightToLeftPagination import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveReaderSliderToggled import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.shouldRenderReaderSlider import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.withTtsReplacements import com.aryan.reader.shared.reader.ReaderJumpHistory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.File import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider" private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed" private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed" private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled" private const val TTS_MODE_KEY = "tts_mode" private const val AUTO_SCROLL_IS_LOCAL_PREFIX = "auto_scroll_is_local_" private const val AUTO_SCROLL_LOCAL_SPEED_PREFIX = "auto_scroll_local_speed_" private const val AUTO_SCROLL_LOCAL_MIN_PREFIX = "auto_scroll_local_min_" private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" private const val TOOL_ORDER_KEY = "reader_tool_order" private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools" private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version" private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2 private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore" private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume" private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TAG_LINK_NAV = "LINK_NAV" private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter" private const val TAG_STABLE_PAGE_NAV = "StablePageNav" private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String { return text .replace('\n', ' ') .replace('\r', ' ') .replace('\t', ' ') .take(maxLength) } private fun List.withInitialChunkOverride( startChunkIndex: Int, initialChunk: TtsChunk? ): List { if (initialChunk == null || startChunkIndex !in indices) return this val existing = this[startChunkIndex] if ( existing.text == initialChunk.text && existing.sourceCfi == initialChunk.sourceCfi && existing.startOffsetInSource == initialChunk.startOffsetInSource ) { return this } return toMutableList().also { chunks -> chunks[startChunkIndex] = initialChunk } } private fun View.bottomRoundedCornerRadiusPx(): Int { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0 val insets = rootWindowInsets ?: return 0 return max( insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0, insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0 ) } @Composable private fun rememberBottomRoundedCornerPadding(view: View): Dp { val density = LocalDensity.current val configuration = LocalConfiguration.current var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) } DisposableEffect( view, configuration.orientation, configuration.screenWidthDp, configuration.screenHeightDp ) { val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ -> radiusPx = updatedView.bottomRoundedCornerRadiusPx() } view.addOnLayoutChangeListener(listener) radiusPx = view.bottomRoundedCornerRadiusPx() onDispose { view.removeOnLayoutChangeListener(listener) } } return with(density) { radiusPx.toDp() } } private fun saveHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) } } private fun loadHiddenTools(context: Context): Set { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) { val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion) prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools) putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) } return migratedHiddenTools } return savedHiddenTools } private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set { return buildSet { if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name) if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name) } } private fun saveToolOrder(context: Context, toolOrder: List) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) } } private fun loadToolOrder(context: Context): List { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val savedTools = prefs.getString(TOOL_ORDER_KEY, null) ?.split(',') ?.filter { it.isNotBlank() } ?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } } .orEmpty() return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct() } private fun saveBottomTools(context: Context, bottomTools: Set) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) } } private fun loadBottomTools(context: Context): Set { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getStringSet( BOTTOM_TOOLS_KEY, defaultReaderBottomTools() ) ?: defaultReaderBottomTools() } private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) } } private fun loadKeepScreenOn(context: Context): Boolean { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(KEEP_SCREEN_ON_KEY, false) } private fun saveMusicianMode(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(MUSICIAN_MODE_KEY, isEnabled) } } private fun loadMusicianMode(context: Context): Boolean { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(MUSICIAN_MODE_KEY, false) } private fun getBookIdForPrefs(title: String): String { return title.hashCode().toString() } private fun saveAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) } } private fun loadAutoScrollLocalMode(context: Context, bookId: String): Boolean { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, false) } private fun saveAutoScrollLocalSettings(context: Context, bookId: String, speed: Float, min: Float, max: Float) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putFloat(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, speed) putFloat(AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, min) putFloat(AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, max) } } private fun loadAutoScrollLocalSettings(context: Context, bookId: String): Triple? { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) if (!prefs.contains(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId)) return null val speed = prefs.getFloat(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, 3.0f) val min = prefs.getFloat(AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, 0.1f) val max = prefs.getFloat(AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, 10.0f) return Triple(speed, min, max) } private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(PAGE_TURN_ANIMATION_KEY, isEnabled) } } private fun loadPageTurnAnimationSetting(context: Context): Boolean { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(PAGE_TURN_ANIMATION_KEY, false) } private fun saveAutoScrollMinSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putFloat(AUTO_SCROLL_MIN_SPEED_KEY, speed) } } private fun loadAutoScrollMinSpeed(context: Context): Float { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getFloat(AUTO_SCROLL_MIN_SPEED_KEY, 0.1f) } private fun saveAutoScrollMaxSpeed(context: Context, speed: Float) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putFloat(AUTO_SCROLL_MAX_SPEED_KEY, speed) } } private fun loadAutoScrollMaxSpeed(context: Context): Float { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getFloat(AUTO_SCROLL_MAX_SPEED_KEY, 10.0f) } private fun saveAutoScrollUseSlider(context: Context, useSlider: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(AUTO_SCROLL_USE_SLIDER_KEY, useSlider) } } private fun loadAutoScrollUseSlider(context: Context): Boolean { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false) } private fun saveTtsMode(context: Context, modeName: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(TTS_MODE_KEY, modeName) } } 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_TRANSLATE_PKG = "external_translate_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private fun loadUseOnlineDict(context: Context): Boolean { @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getBoolean(PREF_USE_ONLINE_DICT, true) } private fun saveUseOnlineDict(context: Context, useOnline: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) } } private fun loadExternalDictPackage(context: Context): String? { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getString(PREF_EXTERNAL_DICT_PKG, null) } private fun saveExternalDictPackage(context: Context, packageName: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) } } private fun loadExternalTranslatePackage(context: Context): String? { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null) } private fun saveExternalTranslatePackage(context: Context, packageName: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) } } private fun loadExternalSearchPackage(context: Context): String? { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null) } private fun saveExternalSearchPackage(context: Context, packageName: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) } } const val PREF_READER_THEME = "reader_theme_id" const val PREF_CUSTOM_THEMES = "custom_themes_json" @UnstableApi @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun EpubReaderScreen( epubBook: EpubBook, renderMode: RenderMode, initialLocator: Locator?, initialCfi: String?, initialBookmarksJson: String?, isProUser: Boolean, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, onNavigateToPro: () -> Unit, coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, onImportFont: (Uri) -> Unit, viewModel: MainViewModel ) { val uiState by viewModel.uiState.collectAsState() val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null val onOpenOriginal: ((Int) -> Unit)? = if (originalBookId != null) { { currentChapter -> val originalItem = uiState.recentFiles.find { it.bookId == originalBookId } if (originalItem != null) { viewModel.switchToFileSeamlessly(originalItem, currentChapter) } else { viewModel.showBanner("Original PDF not found.", true) } } } else null val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) { epubBook.hasReadableExtractedContent() } var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) { mutableStateOf(false) } LaunchedEffect(hasValidExtractionBasePath, uiState.selectedBookId, uiState.selectedEpubUri) { if (!hasValidExtractionBasePath && !requestedContentRecovery && uiState.selectedEpubUri != null) { requestedContentRecovery = true viewModel.recoverSelectedEpubContent() } } if (!hasValidExtractionBasePath) { val isRecovering = uiState.isLoading || (requestedContentRecovery && uiState.errorMessage == null) val message = uiState.errorMessage ?: if (isRecovering) { "Recovering book content..." } else { "Book content not found. Reopen the book to recreate its cache." } Box(modifier = Modifier.fillMaxSize()) { if (isRecovering) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } Text( text = message, modifier = Modifier .align(Alignment.BottomCenter) .padding(24.dp), color = if (uiState.errorMessage != null) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.onSurfaceVariant }, textAlign = TextAlign.Center ) } return } EpubReaderHost( epubBook = epubBook, renderMode = renderMode, initialLocator = initialLocator, initialCfi = initialCfi, initialBookmarksJson = initialBookmarksJson, initialHighlightsJson = uiState.initialHighlightsJson, isProUser = isProUser, credits = uiState.credits, onNavigateBack = onNavigateBack, onSavePosition = onSavePosition, onBookmarksChanged = onBookmarksChanged, onHighlightsChanged = { json -> uiState.selectedBookId?.let { id -> viewModel.saveHighlights(id, json) } }, onNavigateToPro = onNavigateToPro, coverImagePath = coverImagePath, onRenderModeChange = onRenderModeChange, customFonts = customFonts, onImportFont = onImportFont, onToggleReflow = onOpenOriginal, onDeleteReflow = if (isReflowFile) { { uiState.selectedBookId?.let { id -> viewModel.deleteBookPermanently(id) { onNavigateBack() } } } } else null, stableBookId = uiState.selectedBookId, viewModel = viewModel ) } @Suppress("ControlFlowWithEmptyBody") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @androidx.annotation.OptIn(UnstableApi::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(ExperimentalMaterial3Api::class) @Composable fun EpubReaderHost( epubBook: EpubBook, renderMode: RenderMode, initialLocator: Locator?, initialCfi: String?, initialBookmarksJson: String?, initialHighlightsJson: String?, isProUser: Boolean, credits: Int, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, onHighlightsChanged: (highlightsJson: String) -> Unit, onNavigateToPro: () -> Unit, coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, onImportFont: (Uri) -> Unit, onToggleReflow: ((Int) -> Unit)? = null, onDeleteReflow: (() -> Unit)? = null, stableBookId: String? = null, viewModel: MainViewModel ) { val view = LocalView.current val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() val window = (view.context as? Activity)?.window val activity = context as? Activity val scope = rememberCoroutineScope() var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) } var showBrightnessSheet by remember { mutableStateOf(false) } ReaderBrightnessEffect(window, readerBrightnessSettings) val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings -> readerBrightnessSettings = settings saveReaderBrightnessSettings(context, settings) } fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { viewModel.showBanner(message, isError, isPersistent) } DisposableEffect(window, view) { onDispose { window?.let { WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars()) } } } val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() } var isNavigatingToPosition by remember { mutableStateOf(false) } var isSeamlessTransitioning by remember { mutableStateOf(false) } var showInsufficientCreditsDialog by remember { mutableStateOf(false) } var showFileInfoDialog by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var isFastScrubbing by remember { mutableStateOf(false) } val scrubDebounceJob = remember { mutableStateOf(null) } val volumeScrollFocusDebounceJob = remember { mutableStateOf(null) } var sliderStartPage by remember { mutableIntStateOf(0) } var startPageThumbnail by remember { mutableStateOf(null) } var pendingNoteForNewHighlight by remember { mutableStateOf(false) } var highlightToNoteCfi by remember { mutableStateOf(null) } var activeFootnoteHtml by remember { mutableStateOf(null) } var showJustifyWarningDialog by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) } var chunkTargetOverride by remember { mutableStateOf(initialLocator?.let { it.blockIndex / 20 }) } val snackbarHostState = remember { SnackbarHostState() } var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) } var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) } var rightToLeftPagination by remember { mutableStateOf(loadEpubRightToLeftPagination(context)) } var showScreenOrientationSheet by remember { mutableStateOf(false) } var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } ReaderScreenOrientationEffect(screenOrientationMode) var volumeScrollEnabled by remember { mutableStateOf(loadVolumeScrollSetting(context)) } var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) } var isPageTurnAnimationEnabled by remember { mutableStateOf(loadPageTurnAnimationSetting(context)) } var currentTtsMode by remember { mutableStateOf( loadTtsMode(context).let { if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it } ) } val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) { stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) } val bookId = readerCacheBookId var isPageSliderVisible by remember(bookId) { mutableStateOf(loadReaderSliderToggled(context, bookId)) } val locatorConverter = remember(context, readerCacheBookId) { LocatorConverter( bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(), proto = ProtoBuf { serializersModule = semanticBlockModule }, context = context, stableBookId = readerCacheBookId ) } val userHighlights = remember(epubBook.title) { mutableStateListOf().apply { if (initialHighlightsJson != null) { addAll(parseHighlightsJson(initialHighlightsJson)) } else { addAll(loadHighlightsFromPrefs(context, epubBook.title)) } } } LaunchedEffect(userHighlights.size, userHighlights.toList()) { val json = highlightsToJson(userHighlights) onHighlightsChanged(json) if (initialHighlightsJson == null && userHighlights.isNotEmpty()) { clearHighlightsFromPrefs(context, epubBook.title) } } var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isTtsCollapsed by remember { mutableStateOf(false) } var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } val initialSettings = remember(isAutoScrollLocal) { if (isAutoScrollLocal) { loadAutoScrollLocalSettings(context, bookId) ?: Triple( loadAutoScrollSpeed(context), loadAutoScrollMinSpeed(context), loadAutoScrollMaxSpeed(context) ) } else { Triple( loadAutoScrollSpeed(context), loadAutoScrollMinSpeed(context), loadAutoScrollMaxSpeed(context) ) } } var autoScrollSpeed by remember { mutableFloatStateOf(initialSettings.first) } var autoScrollMinSpeed by remember { mutableFloatStateOf(initialSettings.second) } var autoScrollMaxSpeed by remember { mutableFloatStateOf(initialSettings.third) } val onToggleAutoScrollMode = { newIsLocal: Boolean -> isAutoScrollLocal = newIsLocal saveAutoScrollLocalMode(context, bookId, newIsLocal) if (newIsLocal) { val existingLocal = loadAutoScrollLocalSettings(context, bookId) if (existingLocal == null) { saveAutoScrollLocalSettings(context, bookId, autoScrollSpeed, autoScrollMinSpeed, autoScrollMaxSpeed) } else { autoScrollSpeed = existingLocal.first autoScrollMinSpeed = existingLocal.second autoScrollMaxSpeed = existingLocal.third } } else { autoScrollSpeed = loadAutoScrollSpeed(context) autoScrollMinSpeed = loadAutoScrollMinSpeed(context) autoScrollMaxSpeed = loadAutoScrollMaxSpeed(context) } } val updateSpeed = { newSpeed: Float -> autoScrollSpeed = newSpeed if (isAutoScrollLocal) { saveAutoScrollLocalSettings(context, bookId, newSpeed, autoScrollMinSpeed, autoScrollMaxSpeed) } else { saveAutoScrollSpeed(context, newSpeed) } } val updateMinSpeed = { newMin: Float -> autoScrollMinSpeed = newMin if (isAutoScrollLocal) { var currentMax = autoScrollMaxSpeed var currentSpeed = autoScrollSpeed if (currentMax < newMin) { currentMax = newMin; autoScrollMaxSpeed = newMin } if (currentSpeed < newMin) { currentSpeed = newMin; autoScrollSpeed = newMin } else if (currentSpeed > currentMax) { currentSpeed = currentMax; autoScrollSpeed = currentMax } saveAutoScrollLocalSettings(context, bookId, currentSpeed, newMin, currentMax) } else { saveAutoScrollMinSpeed(context, newMin) } } val updateMaxSpeed = { newMax: Float -> autoScrollMaxSpeed = newMax if (isAutoScrollLocal) { var currentMin = autoScrollMinSpeed var currentSpeed = autoScrollSpeed if (currentMin > newMax) { currentMin = newMax; autoScrollMinSpeed = newMax } if (currentSpeed > newMax) { currentSpeed = newMax; autoScrollSpeed = newMax } else if (currentSpeed < currentMin) { currentSpeed = currentMin; autoScrollSpeed = currentMin } saveAutoScrollLocalSettings(context, bookId, currentSpeed, currentMin, newMax) } else { saveAutoScrollMaxSpeed(context, newMax) } } var currentHighlightPalette by remember { mutableStateOf(loadHighlightPalette(context)) } val onUpdateHighlightPalette: (Int, HighlightColor) -> Unit = { index, newColor -> val newList = currentHighlightPalette.toMutableList() if (index in newList.indices) { newList[index] = newColor currentHighlightPalette = newList saveHighlightPalette(context, newList) } } // Dictionary var showAiDefinitionPopup by remember { mutableStateOf(false) } var selectedTextForAi by remember { mutableStateOf(null) } var aiDefinitionResult by remember { mutableStateOf(null) } var isAiDefinitionLoading by remember { mutableStateOf(false) } var showDictionarySettingsSheet by remember { mutableStateOf(false) } var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(context)) } var selectedDictPackage by remember { mutableStateOf(loadExternalDictPackage(context)) } var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) } var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) } var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) } var toolOrder by remember { mutableStateOf(loadToolOrder(context)) } var bottomTools by remember { mutableStateOf(loadBottomTools(context)) } var showCustomizeToolsSheet by remember { mutableStateOf(false) } var showDictionaryUpsellDialog by remember { mutableStateOf(false) } var showSummarizationUpsellDialog by remember { mutableStateOf(false) } @Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String -> val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary if (effectiveUseOnline) { val wordCount = countWords(word) if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) { showDictionaryUpsellDialog = true } else { selectedTextForAi = word showAiDefinitionPopup = true scope.launch { val token = viewModel.getAuthToken() isAiDefinitionLoading = true aiDefinitionResult = null fetchAiDefinition( text = word, onUpdate = { chunk -> val currentDefinition = aiDefinitionResult?.definition ?: "" aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk) }, authToken = token, onError = { error -> if (error == "INSUFFICIENT_CREDITS") { showInsufficientCreditsDialog = true showAiDefinitionPopup = false isAiDefinitionLoading = false } else { aiDefinitionResult = AiDefinitionResult(error = error) } }, onFinish = { isAiDefinitionLoading = false }, context = context ) } } } else { if (!selectedDictPackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word) } else { Toast.makeText(context, context.getString(R.string.toast_select_dictionary_first), Toast.LENGTH_SHORT).show() showDictionarySettingsSheet = true } } } val onTranslateLookup = { text: String -> if (!selectedTranslatePackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchTranslate(context, selectedTranslatePackage!!, text) } else { Toast.makeText(context, context.getString(R.string.toast_select_translate_first), Toast.LENGTH_SHORT).show() showDictionarySettingsSheet = true } } val onSearchLookup = { text: String -> if (!selectedSearchPackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchSearch(context, selectedSearchPackage!!, text) } else { Toast.makeText(context, context.getString(R.string.toast_select_search_first), Toast.LENGTH_SHORT).show() showDictionarySettingsSheet = true } } val summaryCacheManager = remember(context) { SummaryCacheManager(context) } var showRecapPopup by remember { mutableStateOf(false) } var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) } var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) } var chapterToLoadOnSwitch by remember { mutableStateOf(null) } var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) } var paginatedReconfigurationAnchor by remember { mutableStateOf(null) } var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) } val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view) val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp) var bookmarks by remember(epubBook.title) { mutableStateOf( loadBookmarks(context, epubBook.title, epubBook.chapters, initialBookmarksJson).also { Timber.d("Initial load for '${epubBook.title}': ${it.size} bookmarks loaded -> $it") } ) } LaunchedEffect(bookmarks) { Timber.d("Bookmarks changed, saving...") onBookmarksChanged(bookmarksToJson(bookmarks)) } var activeBookmarkInVerticalView by remember { mutableStateOf(null) } var addBookmarkRequest by remember { mutableStateOf(false) } var isChapterReadyForBookmarkCheck by remember { mutableStateOf(false) } var lastBookmarkCheckTime by remember { mutableLongStateOf(0L) } var isSwitchingToPaginated by remember { mutableStateOf(false) } val initialIsAppearanceLightStatusBars = remember(window, view) { window?.let { WindowCompat.getInsetsController( it, view ).isAppearanceLightStatusBars } == true } val initialSystemBarsBehavior = remember(window, view) { window?.let { WindowCompat.getInsetsController(it, view).systemBarsBehavior } ?: WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } val keyboardController = LocalSoftwareKeyboardController.current var isSavingAndExiting by remember { mutableStateOf(false) } var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) } var userStoppedTts by remember { mutableStateOf(false) } var ttsChapterIndex by remember { mutableStateOf(null) } var pendingTtsLocateRequest by remember { mutableStateOf(false) } var pendingTtsLocateReason by remember { mutableStateOf(null) } var hasQueuedInitialTtsLocate by remember(epubBook.title) { mutableStateOf(false) } var isDetachedFromVerticalTts by remember { mutableStateOf(false) } var detachedVerticalTtsChunkKey by remember { mutableStateOf(null) } var suppressNextVerticalTtsDetach by remember { mutableStateOf(false) } var searchHighlightTarget by remember { mutableStateOf(null) } var lastHighlightClickTime by remember { mutableLongStateOf(0L) } var lastScrollHideTime by remember { mutableLongStateOf(0L) } var webViewRefForTts by remember { mutableStateOf(null) } var showAiHubSheet by remember { mutableStateOf(false) } var summarizationResult by remember { mutableStateOf(null) } var isSummarizationLoading by remember { mutableStateOf(false) } var recapResult by remember { mutableStateOf(null) } var isRecapLoading by remember { mutableStateOf(false) } var recapProgressMessage by remember { mutableStateOf("") } var isRequestingRecapCfi by remember { mutableStateOf(false) } val epubSearcher = remember(epubBook) { createEpubSearcher(epubBook) } val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var showBars by remember { mutableStateOf(false) } val chapters = remember(epubBook.chapters) { epubBook.chapters } var readerImages by remember(epubBook) { mutableStateOf>(emptyList()) } LaunchedEffect(epubBook) { readerImages = withContext(Dispatchers.IO) { epubBook.readerImageReferencesForDrawer() } } var currentChapterIndex by rememberSaveable(epubBook.title) { mutableIntStateOf( initialLocator?.chapterIndex?.coerceIn(0, max(0, chapters.size - 1)) ?: 0 ) } LaunchedEffect(chapters.size) { epubJumpHistory = epubJumpHistory.pruned(chapters.size) } var paginator by remember { mutableStateOf(null) } val paginatedPagerState = rememberPagerState(pageCount = { (paginator as? BookPaginator)?.totalPageCount ?: 0 }) var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } var paginatedExplicitNavigationEpoch by remember(epubBook) { mutableLongStateOf(0L) } var paginatedExplicitNavigationAnchor by remember(epubBook) { mutableStateOf(null) } val ttsController = viewModel.ttsController val ttsState by ttsController.ttsState.collectAsState() val totalBookLengthChars = remember(chapters) { chapters.sumOf { it.plainTextContent.length.toLong() } } var topVisibleChunkIndex by remember { mutableIntStateOf(0) } var loadedChunkCount by remember { mutableIntStateOf(1) } var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) } var chapterChunks by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) } var chapterHead by remember(currentChapterIndex) { mutableStateOf("") } var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) } var cfiToLoad by remember { mutableStateOf(initialCfi) } var fragmentToLoad by remember { mutableStateOf(null) } var imageToLoad by remember { mutableStateOf(null) } var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) } var bookmarkPageMap by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(Unit) { Timber.tag("POS_DIAG").d("Reader Opening: initialLocator=$initialLocator, initialCfi=$initialCfi") } var initialScrollTargetForChapter by rememberSaveable(epubBook.title) { mutableStateOf(if (initialLocator != null) null else ChapterScrollPosition.START) } var pullToPrevProgress by remember { mutableFloatStateOf(0f) } var pullToNextProgress by remember { mutableFloatStateOf(0f) } var activeFragmentId by remember { mutableStateOf(null) } val density = LocalDensity.current val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() * pullToTurnMultiplier } var currentScrollYPosition by rememberSaveable(epubBook.title) { mutableIntStateOf(0) } var currentScrollHeightValue by remember { mutableIntStateOf(0) } var currentClientHeightValue by remember { mutableIntStateOf(0) } val currentBookProgress by remember(currentChapterIndex, currentScrollYPosition, currentScrollHeightValue, currentClientHeightValue, totalBookLengthChars) { derivedStateOf { if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(currentChapterIndex) .sumOf { it.plainTextContent.length.toLong() } val progressWithinChapter = if (currentScrollHeightValue > currentClientHeightValue) { val scrollableHeight = (currentScrollHeightValue - currentClientHeightValue).toFloat() if (scrollableHeight > 0) (currentScrollYPosition.toFloat() / scrollableHeight).coerceIn(0f, 1f) else 1f } else if (currentScrollHeightValue > 0) { 1f // Fully scrolled if content is smaller than viewport } else { 0f } val currentChapterLengthChars = chapters.getOrNull(currentChapterIndex)?.plainTextContent?.length?.toLong() ?: 0L val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() val isLastChapter = currentChapterIndex == chapters.size - 1 val isAtEndOfBook = isLastChapter && (currentScrollYPosition + currentClientHeightValue) >= (currentScrollHeightValue - 2) if (isAtEndOfBook) 100f else calculatedProgress } else { 0f } } } var showFormatAdjustmentBars by remember { mutableStateOf(false) } 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 currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) } var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) } var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) } var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) } 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, customFontPath = currentCustomFontPath, assetManager = context.assets ) } var showFontSelectionSheet by remember { mutableStateOf(false) } val fontSheetState = rememberModalBottomSheetState() LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) { if (isFormatLocal) { saveLocalReaderSettings( context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign ) } else { saveReaderSettings( context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign ) } } val configuration = LocalConfiguration.current var lastOrientation by remember { mutableIntStateOf(configuration.orientation) } LaunchedEffect(configuration.orientation) { if (lastOrientation != configuration.orientation) { lastOrientation = configuration.orientation if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { lastKnownLocator?.let { locator -> scope.launch { val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) if (cfi != null) { delay(300L) webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(cfi)}');", null) } } } } } } LaunchedEffect(ttsState.errorMessage) { ttsState.errorMessage?.let { message -> if (message == "INSUFFICIENT_CREDITS") { showInsufficientCreditsDialog = true ttsController.stop() } else { showBanner(message, isError = true) } } } val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0 val epubSliderChromeVisible = shouldRenderReaderSlider( isToggledOn = isPageSliderVisible, isBottomChromeVisible = showBars, isSearchActive = searchState.isSearchActive ) && isEpubSliderReady val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) } var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) } var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } var isMusicianMode by remember { mutableStateOf(loadMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) } var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } DisposableEffect(isKeepScreenOn) { view.keepScreenOn = isKeepScreenOn onDispose { view.keepScreenOn = false } } DisposableEffect(Unit) { onDispose { Timber.d("Disposing sample MediaPlayer.") speakerPlayer.release() } } fun updateAutoScrollState(playing: Boolean, speed: Float) { val effectivePlaying = playing && !isAutoScrollTempPaused updateAutoScrollJs(webViewRefForTts, effectivePlaying, speed * 0.5f) } fun triggerAutoScrollTempPause(durationMs: Long) { if (!isAutoScrollModeActive || !isAutoScrollPlaying) return autoScrollResumeJob.value?.cancel() isAutoScrollTempPaused = true updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed) autoScrollResumeJob.value = scope.launch { delay(durationMs) if (isActive && isAutoScrollModeActive && isAutoScrollPlaying) { isAutoScrollTempPaused = false @Suppress("KotlinConstantConditions") updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed) } } } LaunchedEffect(isAutoScrollModeActive, isAutoScrollPlaying, autoScrollSpeed, isAutoScrollTempPaused) { if (isAutoScrollModeActive) { updateAutoScrollState(isAutoScrollPlaying, autoScrollSpeed) } else { webViewRefForTts?.evaluateJavascript("javascript:window.autoScroll.stop();", null) } } var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsReplacementsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) } var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) } val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next -> ttsReplacementPreferences = next saveTtsReplacementPreferences(context, next) } var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) } val activeTheme = remember(currentThemeId, customThemes) { BuiltInThemes.find { it.id == currentThemeId } ?: customThemes.find { it.id == currentThemeId } ?: BuiltInThemes[0] } val systemIsDark = isSystemInDarkTheme() val isDarkTheme = if (activeTheme.id == "system") systemIsDark else activeTheme.isDark val effectiveBg = remember(activeTheme, systemIsDark) { if (activeTheme.id == "system") { if (systemIsDark) Color(0xFF121212) else Color(0xFFFFFFFF) } else activeTheme.backgroundColor } val effectiveText = remember(activeTheme, systemIsDark) { if (activeTheme.id == "system") { if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000) } else activeTheme.textColor } val epubReaderSliderColors = readerSliderChromeColors( pageBackground = effectiveBg, pageText = effectiveText, themePrimary = MaterialTheme.colorScheme.primary ) val activeTextureId = activeTheme.textureId val activeTextureAlpha = 1f - globalTextureTransparency val activeTextureBitmap = remember(activeTextureId) { loadReaderTextureBitmap(context, activeTextureId) } val activeTextureModifier = activeTextureBitmap?.let { bitmap -> Modifier.drawBehind { drawRect( brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f) ) } } ?: Modifier val infoBarBgColor = remember(effectiveBg, isDarkTheme) { val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f val overlayColor = if (isDarkTheme) Color.White else Color.Black val outR = overlayColor.red * overlayAlpha + effectiveBg.red * (1 - overlayAlpha) val outG = overlayColor.green * overlayAlpha + effectiveBg.green * (1 - overlayAlpha) val outB = overlayColor.blue * overlayAlpha + effectiveBg.blue * (1 - overlayAlpha) Color(outR, outG, outB).copy(alpha = 0.95f) } val currentChapterInPaginatedMode by remember { derivedStateOf { if (currentRenderMode == RenderMode.PAGINATED) { (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage) } else { null } } } fun isActiveReaderTtsForCurrentBook(): Boolean { val isReaderSession = ttsState.playbackSource == "READER" val hasReaderSessionState = ttsState.isPlaying || ttsState.isLoading || ttsState.sessionFinished || ttsState.chapterIndex != null || !ttsState.currentWordSourceCfi.isNullOrBlank() || !ttsState.sourceCfi.isNullOrBlank() || !ttsState.currentText.isNullOrBlank() val isSameBook = ttsState.bookId?.let { it == bookId } ?: (ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title) return isReaderSession && hasReaderSessionState && isSameBook } fun getActiveTtsChapterIndex(): Int? = ttsState.chapterIndex ?: ttsChapterIndex fun buildTtsDiagState(): String { val sourceCfiPreview = ttsState.sourceCfi?.take(48) val pendingCfiPreview = cfiToLoad?.take(48) return "render=$currentRenderMode currentChapter=$currentChapterIndex activeTtsChapter=${getActiveTtsChapterIndex()} " + "pendingLocate=$pendingTtsLocateRequest locateReason=$pendingTtsLocateReason detached=$isDetachedFromVerticalTts suppressDetach=$suppressNextVerticalTtsDetach " + "chunkOverride=$chunkTargetOverride pendingCfi=$pendingCfiPreview ttsCfi=$sourceCfiPreview offset=${ttsState.startOffsetInSource}" } fun logTtsChapterDiag(message: String) { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("$message | ${buildTtsDiagState()}") } fun currentTtsChunkKey(): String? { val cfi = ttsState.sourceCfi?.takeIf { it.isNotBlank() } ?: return null val offset = ttsState.startOffsetInSource.takeIf { it >= 0 } ?: return null return "$cfi@$offset" } fun queuePendingTtsLocate(reason: String) { pendingTtsLocateReason = reason pendingTtsLocateRequest = true } fun detachVerticalReaderFromTts(reason: String) { logTtsChapterDiag("Detaching vertical reader from active TTS chapter. reason=$reason") isDetachedFromVerticalTts = true detachedVerticalTtsChunkKey = currentTtsChunkKey() pendingTtsLocateRequest = false pendingTtsLocateReason = null isNavigatingToPosition = false suppressNextVerticalTtsDetach = false } fun clearPendingTtsRelocationState(reason: String) { logTtsChapterDiag("Clearing pending TTS relocation state. reason=$reason") pendingTtsLocateRequest = false pendingTtsLocateReason = null chunkTargetOverride = null cfiToLoad = null fragmentToLoad = null imageToLoad = null isNavigatingToPosition = false suppressNextVerticalTtsDetach = false } suspend fun saveResolvedLocatorPosition(locator: Locator, cfiForWebView: String?) { lastKnownLocator = locator val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextContent?.length?.toLong() ?: 0L val exactOffset = locatorConverter.getTextOffset(epubBook, locator)?.coerceAtLeast(0) ?: 0 val boundedOffset = exactOffset.coerceAtMost(chapterLengthChars.toInt()).toLong() val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(locator.chapterIndex).sumOf { it.plainTextContent.length.toLong() } val totalCharsScrolled = completedCharsInPreviousChapters + boundedOffset val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() val isAtEndOfBook = locator.chapterIndex == chapters.lastIndex && chapterLengthChars > 0 && boundedOffset >= chapterLengthChars if (isAtEndOfBook) 100f else calculatedProgress } else { 0f } Timber.tag("TTS_LOCATE") .d("Saving resolved locator position. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress") onSavePosition(locator, cfiForWebView, progress) } fun ensureVerticalChunksLoaded(targetChunk: Int) { if (targetChunk >= loadedChunkCount) { val chunksToInject = loadedChunkCount..targetChunk chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) ?: return@forEach webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '${escapeJsString(content)}');", null ) } loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } else { chapterChunks.getOrNull(targetChunk)?.let { content -> webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($targetChunk, '${escapeJsString(content)}');", null ) } } } suspend fun saveActiveTtsPosition(reason: String): Boolean { if (!isActiveReaderTtsForCurrentBook()) return false val chapterIndex = getActiveTtsChapterIndex() ?: return false val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: return false val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: return false logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") saveResolvedLocatorPosition(locator, sourceCfi) return true } suspend fun navigateToActiveTtsPosition(reason: String): Boolean { if (!isActiveReaderTtsForCurrentBook()) { logTtsChapterDiag("navigateToActiveTtsPosition aborted: inactive reader TTS. reason=$reason") return false } val chapterIndex = getActiveTtsChapterIndex() ?: run { logTtsChapterDiag("navigateToActiveTtsPosition aborted: no active TTS chapter. reason=$reason") return false } val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: run { logTtsChapterDiag("navigateToActiveTtsPosition aborted: no active source CFI. reason=$reason") return false } val sourceOffset = ttsState.currentWordStartOffset.takeIf { it >= 0 } ?: ttsState.startOffsetInSource.takeIf { it >= 0 } val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: run { logTtsChapterDiag("navigateToActiveTtsPosition aborted: locator conversion failed. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") return false } val targetChunk = max(0, locator.blockIndex / 20) saveResolvedLocatorPosition(locator, sourceCfi) logTtsChapterDiag("Navigating to active TTS position. reason=$reason targetChapter=$chapterIndex targetChunk=$targetChunk sourceOffset=$sourceOffset") when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { isNavigatingToPosition = true initialScrollTargetForChapter = null isDetachedFromVerticalTts = false detachedVerticalTtsChunkKey = null suppressNextVerticalTtsDetach = true if (chapterIndex != currentChapterIndex) { logTtsChapterDiag("Vertical locate switching chapters. reason=$reason from=$currentChapterIndex to=$chapterIndex targetChunk=$targetChunk") chunkTargetOverride = targetChunk cfiToLoad = sourceCfi currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } else { if (webViewRefForTts == null) { logTtsChapterDiag("Vertical locate queued because WebView is null. reason=$reason targetChunk=$targetChunk") chunkTargetOverride = targetChunk cfiToLoad = sourceCfi } else { logTtsChapterDiag("Vertical locate in current chapter. reason=$reason targetChunk=$targetChunk usingHighlight=${ttsState.currentText?.isNotBlank() == true}") ensureVerticalChunksLoaded(targetChunk) val chunkText = ttsState.currentText?.takeIf { it.isNotBlank() } val chunkStartOffset = ttsState.startOffsetInSource.takeIf { it >= 0 } if (chunkText != null && chunkStartOffset != null) { webViewRefForTts?.evaluateJavascript( "javascript:window.highlightFromCfi('${escapeJsString(sourceCfi)}', '${escapeJsString(chunkText)}', $chunkStartOffset);", null ) } else { webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(sourceCfi)}');", null ) } scope.launch { delay(3000L) if (isNavigatingToPosition) { isNavigatingToPosition = false } } } } return true } RenderMode.PAGINATED -> { if (!isPagerInitialized) { logTtsChapterDiag("Paginated locate aborted: pager not initialized. reason=$reason") return false } val bookPaginator = paginator as? BookPaginator ?: run { logTtsChapterDiag("Paginated locate aborted: paginator unavailable. reason=$reason") return false } val pageIndex = bookPaginator.findStablePageForLocator(locator) ?: bookPaginator.findStableChapterStartPage(chapterIndex) ?: run { logTtsChapterDiag("Paginated locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex") return false } logTtsChapterDiag("Paginated locate scrolling to page=$pageIndex. reason=$reason") isNavigatingToPosition = true paginatedPagerState.scrollToPage(pageIndex) isNavigatingToPosition = false return true } } } val onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit = { targetHighlight, newColor -> val index = userHighlights.indexOfFirst { it.cfi == targetHighlight.cfi } if (index != -1) { userHighlights[index] = targetHighlight.copy(color = newColor) if (currentRenderMode == RenderMode.VERTICAL_SCROLL && targetHighlight.chapterIndex == currentChapterIndex) { val cssClass = newColor.cssClass val jsCommand = "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${escapeJsString(targetHighlight.cfi)}', '$cssClass', '${newColor.id}');" webViewRefForTts?.evaluateJavascript(jsCommand, null) } } } fun startTts() { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return } if (isAutoScrollModeActive) { isAutoScrollModeActive = false isAutoScrollPlaying = false } Timber.d("TTS button clicked: Starting TTS") userStoppedTts = false initiateTtsPlayback( renderMode = currentRenderMode, webView = webViewRefForTts, onPaginatedStart = { scope.launch { val token = viewModel.getAuthToken() val currentPage = paginatedPagerState.currentPage val bookPaginator = paginator as? BookPaginator val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage) if (chapterIndex != null) { val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val pageInChapter = currentPage - chapterStartPage val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex) val firstChunkOnPage = if (pageInChapter > 0) { bookPaginator.getTtsChunksForChapter( chapterIndex = chapterIndex, startingFromPageInChapter = pageInChapter )?.firstOrNull() } else { allTtsChunks?.firstOrNull() } val startChunkIndex = findTtsChunkStartIndex(allTtsChunks.orEmpty(), firstChunkOnPage) ?: 0 if (!allTtsChunks.isNullOrEmpty() && firstChunkOnPage != null) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsChapterIndex = chapterIndex ttsController.start( chunks = allTtsChunks.withInitialChunkOverride(startChunkIndex, firstChunkOnPage) .withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, bookId = bookId, chapterIndex = chapterIndex, totalChapters = chapters.size, startChunkIndex = startChunkIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token ) } } } } ) } var pendingImageDownload by remember { mutableStateOf(null) } val imageSaveLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("image/*"), onResult = { uri -> val image = pendingImageDownload pendingImageDownload = null if (uri != null && image != null) { scope.launch { val saved = withContext(Dispatchers.IO) { runCatching { val bytes = image.readDownloadBytes() ?: error("Image bytes are unavailable") context.contentResolver.openOutputStream(uri)?.use { output -> output.write(bytes) } ?: error("Could not open image destination") }.isSuccess } val message = if (saved) { context.getString(R.string.saved_image_message, image.suggestedDownloadFileName()) } else { context.getString(R.string.error_save_image) } Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } } ) val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { _ -> startTts() } ) fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return } val action = { scope.launch { val token = viewModel.getAuthToken() val bookPaginator = paginator as? BookPaginator val chapterIndex = currentChapterInPaginatedMode ?: return@launch val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch val foundIdx = findTtsChunkStartIndex( chunks = chunks, target = TtsChunk( text = "", sourceCfi = baseCfi, startOffsetInSource = startOffset ) ) ?: -1 if (foundIdx != -1) { val target = chunks[foundIdx] val relativeOffset = startOffset - target.startOffsetInSource val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length) val slicedText = target.text.substring(safeRelativeOffset) val newChunk = target.copy( text = slicedText, startOffsetInSource = startOffset, spokenText = slicedText, ) val sessionChunks = chunks.toMutableList().also { it[foundIdx] = newChunk } if (sessionChunks.isNotEmpty()) { ttsShouldStartOnChapterLoad = false ttsChapterIndex = chapterIndex val chapterTitle = chapters.getOrNull(chapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsController.start( chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, bookId = bookId, chapterIndex = chapterIndex, totalChapters = chapters.size, startChunkIndex = foundIdx, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token ) } } } } if (isAutoScrollModeActive) { isAutoScrollModeActive = false isAutoScrollPlaying = false } userStoppedTts = false if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { action() } else if (activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true) { showPermissionRationaleDialog = true } else { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } TtsSessionObserver( ttsState = ttsState, ttsController = ttsController, currentRenderMode = currentRenderMode, chapters = chapters, epubBookTitle = epubBook.title, coverImagePath = coverImagePath, webViewRef = webViewRefForTts, loadedChunkCount = loadedChunkCount, totalChunksInChapter = chapterChunks.size, paginator = paginator, pagerState = paginatedPagerState, ttsChapterIndex = ttsChapterIndex, onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex }, onNavigateToChapter = { nextIndex -> Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TTS_CHAPTER_CHANGE, from=$currentChapterIndex, to=$nextIndex") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex") initialScrollTargetForChapter = ChapterScrollPosition.START cfiToLoad = null currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = nextIndex }, onToggleTtsStartOnLoad = { shouldStart -> Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart") ttsShouldStartOnChapterLoad = shouldStart }, userStoppedTts = userStoppedTts, scope = scope, currentTtsMode = currentTtsMode, getAuthToken = { viewModel.getAuthToken() }, locatorConverter = locatorConverter, epubBook = epubBook, ttsReplacementPreferences = ttsReplacementPreferences, ttsReplacementBookId = bookId ) TtsHighlightHandler( ttsState = ttsState, currentRenderMode = currentRenderMode, currentChapterIndex = currentChapterIndex, webViewRef = webViewRefForTts, paginator = paginator, pagerState = paginatedPagerState, ttsChapterIndex = ttsChapterIndex, scope = scope ) EpubReaderSearchEffects( searchState = searchState, webViewRef = webViewRefForTts, currentChapterIndex = currentChapterIndex, focusRequester = searchFocusRequester ) val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) { if (currentClientHeightValue > 0) { max( 1, ceil(currentScrollHeightValue.toFloat() / currentClientHeightValue.toFloat()).toInt() ) } else { 1 } } val currentPageInChapter = remember( currentScrollYPosition, currentClientHeightValue, currentScrollHeightValue, totalPagesInCurrentChapter ) { if (currentClientHeightValue > 0 && currentScrollHeightValue > 0) { val normalizedScrollY = max(0, currentScrollYPosition) if (currentScrollHeightValue <= currentClientHeightValue) { 1 } else { val isAtBottom = (normalizedScrollY + currentClientHeightValue) >= (currentScrollHeightValue - 2) val calculatedPage = if (isAtBottom) { totalPagesInCurrentChapter } else { floor(normalizedScrollY.toFloat() / currentClientHeightValue.toFloat()).toInt() + 1 } max(1, min(calculatedPage, totalPagesInCurrentChapter)) } } else { 1 } } fun currentEpubSliderPage(): Int { return when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> currentPageInChapter RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1) } } fun resetEpubSliderBookmark() { val position = readerSliderBookmarkPosition(currentEpubSliderPage()) sliderStartPage = position.startPage sliderCurrentPage = position.currentPage } LaunchedEffect(bookId, isPageSliderVisible) { saveReaderSliderToggled(context, bookId, isPageSliderVisible) if (isPageSliderVisible) { resetEpubSliderBookmark() } } fun toggleEpubPageSlider() { if (!isPageSliderVisible && currentRenderMode == RenderMode.PAGINATED && paginatedPagerState.pageCount <= 0) { showBanner("Book is not paginated yet.") return } val nextState = readerSliderToggleState( isCurrentlyToggledOn = isPageSliderVisible, currentPage = currentEpubSliderPage() ) sliderStartPage = nextState.bookmarkPosition.startPage sliderCurrentPage = nextState.bookmarkPosition.currentPage isPageSliderVisible = nextState.isToggledOn showBars = true if (nextState.isToggledOn) { showFormatAdjustmentBars = false } } LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) { if (isPageSliderVisible && !epubSliderChromeVisible) { resetEpubSliderBookmark() } } LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) { if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) { startPageThumbnail?.recycle() startPageThumbnail = webViewRefForTts?.let { webView -> captureWebViewVisibleArea(webView) } } else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) { startPageThumbnail?.recycle() startPageThumbnail = null } } val latestChapterIndex by rememberUpdatedState(currentChapterIndex) LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) { if (!hasQueuedInitialTtsLocate && isActiveReaderTtsForCurrentBook()) { logTtsChapterDiag("Queueing initial TTS locate from active session restoration") queuePendingTtsLocate(TTS_LOCATE_REASON_INITIAL_RESTORE) hasQueuedInitialTtsLocate = true } } LaunchedEffect( pendingTtsLocateRequest, pendingTtsLocateReason, currentRenderMode, webViewRefForTts, paginator, isPagerInitialized, ttsState.bookTitle, ttsState.chapterIndex, ttsChapterIndex, ttsState.sourceCfi, loadedChunkCount, chapterChunks.size, isDetachedFromVerticalTts ) { if (!pendingTtsLocateRequest) return@LaunchedEffect if (!isActiveReaderTtsForCurrentBook()) { logTtsChapterDiag("Dropping pending TTS locate because session is no longer active for this book") pendingTtsLocateRequest = false pendingTtsLocateReason = null return@LaunchedEffect } if ( currentRenderMode == RenderMode.VERTICAL_SCROLL && isDetachedFromVerticalTts && pendingTtsLocateReason != TTS_LOCATE_REASON_OVERLAY ) { logTtsChapterDiag("Dropping automatic TTS locate because the vertical reader is intentionally detached") pendingTtsLocateRequest = false pendingTtsLocateReason = null return@LaunchedEffect } logTtsChapterDiag("Processing pending TTS locate request") if (navigateToActiveTtsPosition("pending_request")) { logTtsChapterDiag("Pending TTS locate request completed successfully") pendingTtsLocateRequest = false pendingTtsLocateReason = null } else { logTtsChapterDiag("Pending TTS locate request did not navigate yet") } } LaunchedEffect( currentRenderMode, currentChapterIndex, ttsState.playbackSource, ttsState.chapterIndex, ttsChapterIndex ) { if (currentRenderMode != RenderMode.VERTICAL_SCROLL) return@LaunchedEffect if (!isActiveReaderTtsForCurrentBook()) { logTtsChapterDiag("Vertical detach effect resetting because active reader TTS is unavailable") isDetachedFromVerticalTts = false detachedVerticalTtsChunkKey = null suppressNextVerticalTtsDetach = false return@LaunchedEffect } val activeTtsChapterIndex = getActiveTtsChapterIndex() ?: return@LaunchedEffect if (currentChapterIndex == activeTtsChapterIndex) { logTtsChapterDiag("Vertical detach effect cleared because reader is back on the active TTS chapter") suppressNextVerticalTtsDetach = false isDetachedFromVerticalTts = false detachedVerticalTtsChunkKey = null return@LaunchedEffect } if (suppressNextVerticalTtsDetach) { val hasPendingProgrammaticNavigation = isNavigatingToPosition || chunkTargetOverride != null || !cfiToLoad.isNullOrBlank() if (hasPendingProgrammaticNavigation) { logTtsChapterDiag("Vertical detach suppression consumed after programmatic TTS navigation") suppressNextVerticalTtsDetach = false return@LaunchedEffect } logTtsChapterDiag("Ignoring stale vertical detach suppression and honoring manual chapter movement") suppressNextVerticalTtsDetach = false } if (!isDetachedFromVerticalTts) { detachVerticalReaderFromTts("chapter_mismatch") } } LaunchedEffect( currentRenderMode, isDetachedFromVerticalTts, ttsState.sourceCfi, ttsState.startOffsetInSource, ttsState.chapterIndex, ttsChapterIndex ) { if (currentRenderMode != RenderMode.VERTICAL_SCROLL) return@LaunchedEffect if (!isDetachedFromVerticalTts) return@LaunchedEffect if (!isActiveReaderTtsForCurrentBook()) return@LaunchedEffect val currentChunkKey = currentTtsChunkKey() ?: return@LaunchedEffect val detachedChunkKey = detachedVerticalTtsChunkKey if (detachedChunkKey == null) { logTtsChapterDiag("Detached vertical reader recorded first observed TTS chunk key") detachedVerticalTtsChunkKey = currentChunkKey return@LaunchedEffect } if (currentChunkKey == detachedChunkKey) { logTtsChapterDiag("Detached vertical reader waiting for next TTS chunk boundary before rejoining") return@LaunchedEffect } logTtsChapterDiag("Detached vertical reader detected next TTS chunk boundary and will try to rejoin") if (navigateToActiveTtsPosition("chunk_follow")) { logTtsChapterDiag("Detached vertical reader rejoined active TTS chapter successfully") isDetachedFromVerticalTts = false detachedVerticalTtsChunkKey = null } else { logTtsChapterDiag("Detached vertical reader failed to rejoin on this chunk boundary") } } val lifecycleOwner = LocalLifecycleOwner.current val latestWebViewRefForTts by rememberUpdatedState(webViewRefForTts) val latestIsActiveReaderTtsForCurrentBook by rememberUpdatedState(isActiveReaderTtsForCurrentBook()) val latestSaveActiveTtsPosition by rememberUpdatedState Boolean>({ reason -> saveActiveTtsPosition(reason) }) val latestIsDetachedFromVerticalTts by rememberUpdatedState(isDetachedFromVerticalTts) val latestCurrentRenderMode by rememberUpdatedState(currentRenderMode) val latestQueueLifecycleTtsLocate by rememberUpdatedState({ if (latestCurrentRenderMode == RenderMode.VERTICAL_SCROLL && latestIsDetachedFromVerticalTts) { logTtsChapterDiag("Lifecycle resume skipped automatic TTS locate because the vertical reader is detached") } else { logTtsChapterDiag("Lifecycle resume queued a TTS locate request") queuePendingTtsLocate(TTS_LOCATE_REASON_LIFECYCLE_RESUME) } }) DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { if (latestIsActiveReaderTtsForCurrentBook) { scope.launch { if (!latestSaveActiveTtsPosition("lifecycle_pause")) { Timber.d("ON_PAUSE detected. Falling back to WebView CFI save.") latestWebViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) } } } else { Timber.d("ON_PAUSE detected. Requesting final CFI for robust save.") latestWebViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) } } else if (event == Lifecycle.Event.ON_RESUME && latestIsActiveReaderTtsForCurrentBook) { latestQueueLifecycleTtsLocate() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } DisposableEffect(Unit) { onDispose { Timber.d("Disposing reader. Last known chapter was ${latestChapterIndex}. Position saved periodically.") webViewRefForTts = null chapterHead = "" chapterChunks = emptyList() chapterChunkElementStartIndices = emptyList() chapterChunkElementCounts = emptyList() startPageThumbnail?.recycle() startPageThumbnail = null autoScrollResumeJob.value?.cancel() autoScrollResumeJob.value = null } } LaunchedEffect(currentScrollYPosition, isChapterReadyForBookmarkCheck) { if (!isChapterReadyForBookmarkCheck) return@LaunchedEffect delay(1500L) Timber.d("User stopped scrolling. Requesting CFI for auto-save...") webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) } val runRecap = { chapterIdx: Int, charLimit: Int -> showAiHubSheet = true isRecapLoading = true recapResult = null recapProgressMessage = "Checking past chapters..." scope.launch { val token = viewModel.getAuthToken() var currentCost: Double? = null executeRecapLogic( epubBook = epubBook, chapterIndex = chapterIdx, characterLimit = charLimit, summaryCacheManager = summaryCacheManager, paginator = paginator, context = context, onProgressUpdate = { recapProgressMessage = it }, onCostReceived = { cost -> currentCost = cost recapResult = recapResult?.copy(cost = cost) ?: SummarizationResult(cost = cost) }, onResultUpdate = { chunk -> isRecapLoading = false val current = recapResult?.summary ?: "" recapResult = SummarizationResult( summary = current + chunk, cost = currentCost ) }, authToken = token, onError = { error -> if (error == "INSUFFICIENT_CREDITS") { showInsufficientCreditsDialog = true showRecapPopup = false isRecapLoading = false } else { recapResult = SummarizationResult(error = error) } }, onFinish = { isRecapLoading = false } ) } } LaunchedEffect(currentChapterIndex) { isChapterParsing = true isChapterReadyForBookmarkCheck = false activeFragmentId = null val result = loadChapterContent( context = context, epubBook = epubBook, chapterIndex = currentChapterIndex, chunkTargetOverride = chunkTargetOverride, isInitialCfiLoad = isInitialCfiLoad, cfiToLoad = cfiToLoad, locatorConverter = locatorConverter ) chapterHead = result.head chapterChunks = result.chunks chapterChunkElementStartIndices = result.chunkElementStartIndices chapterChunkElementCounts = result.chunkElementCounts isChapterParsing = false if (initialScrollTargetForChapter == ChapterScrollPosition.END) { loadUpToChunkIndex = max(0, result.chunks.size - 1) loadedChunkCount = result.chunks.size topVisibleChunkIndex = loadUpToChunkIndex } else { loadUpToChunkIndex = result.startChunkIndex loadedChunkCount = min(result.chunks.size, result.startChunkIndex + 2) topVisibleChunkIndex = 0 } Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing") if (chunkTargetOverride != null) { chunkTargetOverride = null } if (isInitialCfiLoad) { isInitialCfiLoad = false } } EpubReaderSystemUiController( window = window, view = view, showBars = showBars, initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars, initialSystemBarsBehavior = initialSystemBarsBehavior, isDarkTheme = isDarkTheme, systemUiMode = systemUiMode ) LaunchedEffect(paginator, currentRenderMode, isPagerInitialized) { Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: Checking paginator init. currentRenderMode=$currentRenderMode, paginator=${paginator != null}, isPagerInitialized=$isPagerInitialized") if (currentRenderMode == RenderMode.PAGINATED && paginator != null && !isPagerInitialized) { scope.launch { val bookPaginator = paginator as? BookPaginator val targetChapterIndex = lastKnownLocator?.chapterIndex ?: chapterToLoadOnSwitch ?: initialLocator?.chapterIndex ?: 0 if (bookPaginator != null) { withTimeoutOrNull(5000L) { snapshotFlow { bookPaginator.chapterPageCounts[targetChapterIndex] } .filter { it != null && it > 0 } .first() } } val pageToScrollTo = lastKnownLocator?.let { locator -> Timber.d("Paginator ready. Finding page for locator: $locator") (paginator as? BookPaginator)?.findPageForLocator(locator) } ?: run { Timber.d("Paginator ready, but no locator. Falling back to chapter start.") val chapterToLoad = chapterToLoadOnSwitch ?: initialLocator?.chapterIndex ?: 0 (paginator as? BookPaginator)?.chapterStartPageIndices?.get(chapterToLoad) ?: 0 } @Suppress("SENSELESS_COMPARISON") if (pageToScrollTo != null) { Timber.d("Scrolling to page: $pageToScrollTo") paginatedPagerState.scrollToPage(pageToScrollTo) } else { Timber.w("Could not determine a page to scroll to.") } delay(100) isPagerInitialized = true chapterToLoadOnSwitch = null } } } LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) { if (currentRenderMode != RenderMode.PAGINATED || paginator == null || !isPagerInitialized) { return@LaunchedEffect } snapshotFlow { paginatedPagerState.currentPage } .collectLatest { page -> if (!isPaginatedReconfigurationRestoring) { (paginator as? BookPaginator)?.getLocatorForPage(page)?.let { locator -> lastKnownLocator = locator } } } } LaunchedEffect(paginatedPagerState.currentPage, paginator, isPaginatedReconfigurationRestoring) { if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized && !isPaginatedReconfigurationRestoring ) { delay(1500L) val pageToSave = paginatedPagerState.currentPage val locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave) val chapterIndex = paginator!!.findChapterIndexForPage(pageToSave) if (locator != null && chapterIndex != null) { lastKnownLocator = locator val bookPaginator = paginator as? BookPaginator val progress = if (totalBookLengthChars > 0 && bookPaginator != null) { val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() val isLastPageOfBook = pageToSave == paginatedPagerState.pageCount - 1 if (isLastPageOfBook) 100f else calculatedProgress } else { 0f } Timber.d("Auto-saving paginated position. Page: $pageToSave, Locator: $locator, Progress: $progress%" ) onSavePosition(locator, null, progress) } else { Timber.w("Could not auto-save paginated position. Locator or chapterIndex was null.") } } } val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding val isPageInfoVisible = when (pageInfoMode) { PageInfoMode.DEFAULT -> !showBars PageInfoMode.SYNC -> showBars PageInfoMode.HIDDEN -> false } LaunchedEffect(bookmarks, paginator) { paginator ?: return@LaunchedEffect val bookPaginator = paginator as? BookPaginator if (bookPaginator == null) { Timber.w("Paginator is not a BookPaginator instance, cannot calculate bookmark page map.") return@LaunchedEffect } Timber.d("Paginator or bookmarks changed. Re-calculating bookmark page map for ${bookmarks.size} bookmarks.") val newMap = bookmarkPageMap.toMutableMap() bookmarks.forEach { bookmark -> if (newMap.containsKey(bookmark.cfi)) return@forEach scope.launch { val locator = locatorConverter.getLocatorFromCfi( book = epubBook, chapterIndex = bookmark.chapterIndex, cfi = bookmark.cfi ) if (locator != null) { Timber.d("Bookmark map: Converted CFI '${bookmark.cfi}' to Locator: $locator") val pageIndex = bookPaginator.findPageForLocator(locator) if (pageIndex != null) { Timber.d("Bookmark map: Found page $pageIndex for locator.") newMap[bookmark.cfi] = pageIndex bookmarkPageMap = newMap.toMap() } else { Timber.w("Bookmark map: Could not find page for locator: $locator.") } } else { Timber.w("Bookmark map: Failed to convert CFI '${bookmark.cfi}' to locator. Cannot map this bookmark.") } } } } LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) { if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) { val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage) if (chapterIndex != null) { val chapterPath = chapters.getOrNull(chapterIndex)?.absPath val relevantAnchors = epubBook.tableOfContents .filter { it.absolutePath == chapterPath && it.fragmentId != null } .mapNotNull { it.fragmentId } if (relevantAnchors.isNotEmpty()) { val active = paginator!!.getActiveAnchorForPage( paginatedPagerState.currentPage, relevantAnchors ) if (activeFragmentId != active) { Timber.tag("FRAG_NAV_DEBUG").d("P-Mode Active Anchor: $active") activeFragmentId = active } } else { if (activeFragmentId != null) activeFragmentId = null } } } } fun triggerSaveAndExit() { if (!isSavingAndExiting) { Timber.d("Triggering final save before exiting.") isSavingAndExiting = true when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null ) } RenderMode.PAGINATED -> { scope.launch { val pageToSave = paginatedPagerState.currentPage val pageLocator = if (isPaginatedReconfigurationRestoring) { null } else { (paginator as? BookPaginator)?.getLocatorForPage(pageToSave) } val locator = pageLocator ?: paginatedReconfigurationAnchor ?: lastKnownLocator val chapterIndex = paginator?.findChapterIndexForPage(pageToSave) if (locator != null) { val bookPaginator = paginator as? BookPaginator val progress = if (pageLocator == null || chapterIndex == null) { saveResolvedLocatorPosition(locator, null) onNavigateBack() return@launch } else if (totalBookLengthChars > 0 && bookPaginator != null) { val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() val isLastPageOfBook = pageToSave == paginatedPagerState.pageCount - 1 if (isLastPageOfBook) 100f else calculatedProgress } else { 0f } Timber.d("Final save for paginated view. Page: $pageToSave, Locator: $locator, Progress: $progress%" ) onSavePosition(locator, null, progress) } else { Timber.w("Final save for paginated view failed. Locator is null." ) } onNavigateBack() } return } } scope.launch { delay(1500L) if (isSavingAndExiting) { Timber.w("CFI save on exit timed out. Navigating back.") onNavigateBack() } } } } fun Locator.toEpubJumpLocator(pageIndex: Int? = null, cfiOverride: String? = null): SharedReaderLocator { return SharedReaderLocator( chapterIndex = chapterIndex, pageIndex = pageIndex, cfi = cfiOverride ?: "android-locator:$chapterIndex:$blockIndex:$charOffset" ) } fun SharedReaderLocator.toAndroidLocatorOrNull(): Locator? { val parts = cfi ?.takeIf { it.startsWith("android-locator:") } ?.split(':') ?: return null return Locator( chapterIndex = parts.getOrNull(1)?.toIntOrNull() ?: return null, blockIndex = parts.getOrNull(2)?.toIntOrNull() ?: return null, charOffset = parts.getOrNull(3)?.toIntOrNull() ?: return null ) } fun currentEpubJumpLocator(): SharedReaderLocator? { return when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> SharedReaderLocator( chapterIndex = currentChapterIndex, cfi = "android-scroll:$currentScrollYPosition" ) RenderMode.PAGINATED -> { val pageIndex = paginatedPagerState.currentPage.takeIf { it >= 0 } val locator = (paginator as? BookPaginator)?.getLocatorForPage(paginatedPagerState.currentPage) val fallbackLocator = lastKnownLocator?.takeIf { currentChapterInPaginatedMode != null && it.chapterIndex == currentChapterInPaginatedMode } locator?.toEpubJumpLocator(pageIndex = pageIndex) ?: fallbackLocator?.toEpubJumpLocator(pageIndex = pageIndex) } } } fun chapterStartJumpLocator(chapterIndex: Int): SharedReaderLocator { return SharedReaderLocator( chapterIndex = chapterIndex, href = chapters.getOrNull(chapterIndex)?.absPath, cfi = "android-scroll:0" ) } fun fragmentJumpLocator(chapterIndex: Int, fragment: String?, href: String? = null): SharedReaderLocator { return SharedReaderLocator( chapterIndex = chapterIndex, href = href ?: chapters.getOrNull(chapterIndex)?.absPath, cfi = fragment?.let { "android-fragment:$it" } ?: "android-scroll:0" ) } fun cfiJumpLocator(chapterIndex: Int, cfi: String, textQuote: String? = null): SharedReaderLocator { return SharedReaderLocator( chapterIndex = chapterIndex, cfi = cfi, textQuote = textQuote ) } fun recordEpubJump(target: SharedReaderLocator?) { epubJumpHistory = epubJumpHistory.record( currentLocator = currentEpubJumpLocator(), targetLocator = target, chapterCount = chapters.size ) } fun paginatedJumpLocatorForPage( pageIndex: Int, targetLocator: Locator? = null, fallbackChapterIndex: Int? = null, allowPageFallback: Boolean = false ): SharedReaderLocator? { val safePageIndex = when { pageIndex < 0 -> return null paginatedPagerState.pageCount > 0 -> pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1) else -> pageIndex } val bookPaginator = paginator as? BookPaginator val resolvedLocator = targetLocator ?: bookPaginator?.getLocatorForPage(safePageIndex) if (resolvedLocator != null) { return resolvedLocator.toEpubJumpLocator(pageIndex = safePageIndex) } if (!allowPageFallback) return null val chapterIndex = fallbackChapterIndex ?: bookPaginator?.findChapterIndexForPage(safePageIndex) return SharedReaderLocator( chapterIndex = chapterIndex, pageIndex = safePageIndex, cfi = "android-page:$safePageIndex" ) } suspend fun scrollPaginatedToJumpPage( pageIndex: Int, targetLocator: Locator? = null, fallbackToChapterStart: Boolean = false ) { if (paginatedPagerState.pageCount <= 0) return val targetPageIndex = pageIndex.coerceIn(0, paginatedPagerState.pageCount - 1) val bookPaginator = paginator as? BookPaginator val resolvedLocator = targetLocator ?: bookPaginator?.getLocatorForPage(targetPageIndex) ?: if (fallbackToChapterStart) { bookPaginator ?.findChapterIndexForPage(targetPageIndex) ?.let { Locator(chapterIndex = it, blockIndex = 0, charOffset = 0) } } else { null } if (resolvedLocator != null) { lastKnownLocator = resolvedLocator } val navigationEpoch = System.currentTimeMillis() paginatedExplicitNavigationEpoch = navigationEpoch paginatedExplicitNavigationAnchor = resolvedLocator Timber.tag(TAG_STABLE_PAGE_NAV).d( "external_scroll_request requestedPage=$pageIndex targetPage=$targetPageIndex anchor=$resolvedLocator fallbackToChapterStart=$fallbackToChapterStart pageCount=${paginatedPagerState.pageCount} epoch=$navigationEpoch" ) bookPaginator?.onUserScrolledTo(targetPageIndex) paginatedPagerState.scrollToPage(targetPageIndex) Timber.tag(TAG_STABLE_PAGE_NAV).d( "external_scroll_complete targetPage=$targetPageIndex currentPage=${paginatedPagerState.currentPage} anchor=$resolvedLocator epoch=$navigationEpoch" ) } fun SharedReaderLocator.epubJumpLabel(): String { val targetPageIndex = pageIndex val targetCfi = cfi.orEmpty() if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) { return context.getString(R.string.pdf_page_short, targetPageIndex + 1) } val chapter = chapterIndex return if (chapter != null) { chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chapter_number_format, chapter + 1) } else { context.getString(R.string.location_generic) } } fun injectVerticalChunksThrough(targetChunk: Int) { if (targetChunk < loadedChunkCount) return (loadedChunkCount..targetChunk).forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } fun scrollCurrentVerticalChapterToFragment(fragment: String) { val escapedFragment = escapeJsString(fragment) val js = """ (function() { var targetId = '$escapedFragment'; var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); return -2; } if (window.virtualization && window.virtualization.chunksData) { for (var i = 0; i < window.virtualization.chunksData.length; i++) { var chunkHtml = window.virtualization.chunksData[i]; if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { return i; } } } return -1; })() """.trimIndent() webViewRefForTts?.evaluateJavascript(js) { result -> val chunkIdx = result?.toIntOrNull() ?: -1 if (chunkIdx >= 0) { injectVerticalChunksThrough(chunkIdx) val scrollJs = """ (function() { var chunkIndex = $chunkIdx; var fragmentId = '$escapedFragment'; var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); if (chunkDiv) { if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; chunkDiv.style.height = ""; } setTimeout(function() { var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } else { var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } }, 150); } })() """.trimIndent() webViewRefForTts?.evaluateJavascript(scrollJs, null) } else if (chunkIdx == -1) { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } } fun scrollCurrentVerticalChapterToImage(image: EpubReaderImageReference) { val targetChunk = image.chunkIndex if (targetChunk != null && targetChunk >= 0) { injectVerticalChunksThrough(targetChunk) } val escapedSource = escapeJsString(image.sourcePath) val escapedOriginalSource = escapeJsString(image.originalSource) webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToReaderImageSource('$escapedSource', ${image.ordinalInChapter}, '$escapedOriginalSource');", null ) } fun navigateVerticalToImage(image: EpubReaderImageReference) { scope.launch { recordEpubJump(chapterStartJumpLocator(image.chapterIndex)) clearPendingTtsRelocationState("sidebar_image_vertical") imageToLoad = image cfiToLoad = null fragmentToLoad = null initialScrollTargetForChapter = null if (image.chapterIndex != currentChapterIndex) { chunkTargetOverride = image.chunkIndex?.coerceAtLeast(0) Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=SIDEBAR_IMAGE, from=$currentChapterIndex, to=${image.chapterIndex}, image='${image.sourceName()}'") currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = image.chapterIndex } else { chunkTargetOverride = null scrollCurrentVerticalChapterToImage(image) imageToLoad = null } } } fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { scope.launch { val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) val targetChunk = locator?.let { it.blockIndex / 20 } cfiToLoad = cfi initialScrollTargetForChapter = null if (chapterIndex != currentChapterIndex) { chunkTargetOverride = targetChunk?.coerceAtLeast(0) ?: 0 currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } else { if (targetChunk != null && targetChunk >= 0) { injectVerticalChunksThrough(targetChunk) } webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(cfi)}');", null ) } } } fun navigateToEpubJumpLocator(locator: SharedReaderLocator) { scope.launch { val chapterIndex = locator.chapterIndex?.coerceIn(0, max(0, chapters.lastIndex)) val cfi = locator.cfi.orEmpty() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { clearPendingTtsRelocationState("epub_jump_history") when { cfi.startsWith("android-scroll:") -> { val scrollY = cfi.substringAfter("android-scroll:").toIntOrNull() ?: 0 initialScrollTargetForChapter = null if (chapterIndex != null && chapterIndex != currentChapterIndex) { currentScrollYPosition = scrollY currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0, $scrollY);", null) } } cfi.startsWith("android-fragment:") -> { val fragment = cfi.substringAfter("android-fragment:") initialScrollTargetForChapter = null fragmentToLoad = fragment if (chapterIndex != null && chapterIndex != currentChapterIndex) { currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } else { scrollCurrentVerticalChapterToFragment(fragment) } } cfi.startsWith("android-search:") -> { val parts = cfi.split(':') val targetChunk = parts.getOrNull(1)?.toIntOrNull() ?: 0 val occurrence = parts.getOrNull(2)?.toIntOrNull() ?: 0 initialScrollTargetForChapter = null if (chapterIndex != null && chapterIndex != currentChapterIndex) { chunkTargetOverride = targetChunk.coerceAtLeast(0) searchHighlightTarget = searchState.searchResults.firstOrNull { it.locationInSource == chapterIndex && it.chunkIndex == targetChunk && it.occurrenceIndexInLocation == occurrence } currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } else { injectVerticalChunksThrough(targetChunk) webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToOccurrence($occurrence);", null ) } } cfi.startsWith("android-locator:") -> { val androidLocator = locator.toAndroidLocatorOrNull() val targetCfi = androidLocator?.let { locatorConverter.getCfiFromLocator(epubBook, it) } if (androidLocator != null && targetCfi != null) { navigateVerticalToCfi(androidLocator.chapterIndex, targetCfi) } else if (chapterIndex != null) { initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIndex } } cfi.startsWith("android-page:") && chapterIndex != null -> { initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 if (chapterIndex != currentChapterIndex) { currentChapterIndex = chapterIndex } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null -> navigateVerticalToCfi(chapterIndex, cfi) chapterIndex != null -> { initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 if (chapterIndex != currentChapterIndex) { currentChapterIndex = chapterIndex } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } } } RenderMode.PAGINATED -> { val bookPaginator = paginator as? BookPaginator val directPage = locator.pageIndex?.takeIf { it in 0 until paginatedPagerState.pageCount } isNavigatingToPosition = true try { when { cfi.startsWith("android-locator:") && bookPaginator != null -> { val androidLocator = locator.toAndroidLocatorOrNull() val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) } if (targetPage != null) { scrollPaginatedToJumpPage(targetPage, androidLocator) } else if (directPage != null) { scrollPaginatedToJumpPage(directPage) } } cfi.isNotBlank() && !cfi.startsWith("android-") && chapterIndex != null && bookPaginator != null -> { val androidLocator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) val targetPage = androidLocator?.let { bookPaginator.findStablePageForLocator(it) } if (targetPage != null) { scrollPaginatedToJumpPage(targetPage, androidLocator) } else if (directPage != null) { scrollPaginatedToJumpPage(directPage) } else { bookPaginator.findStableChapterStartPage(chapterIndex)?.let { scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true) } } } cfi.startsWith("android-fragment:") && directPage != null -> scrollPaginatedToJumpPage(directPage) cfi.startsWith("android-search:") && directPage != null -> scrollPaginatedToJumpPage(directPage) cfi.startsWith("android-page:") && directPage != null -> scrollPaginatedToJumpPage(directPage) directPage != null -> scrollPaginatedToJumpPage(directPage) chapterIndex != null && bookPaginator != null -> { bookPaginator.findStableChapterStartPage(chapterIndex)?.let { scrollPaginatedToJumpPage(it, Locator(chapterIndex, 0, 0), fallbackToChapterStart = true) } } } } finally { isNavigatingToPosition = false } } } if (showBars) showBars = false } } fun goBackInEpubJumpHistory() { val target = epubJumpHistory.backLocator ?: return epubJumpHistory = epubJumpHistory.stepBack() navigateToEpubJumpLocator(target) } fun goForwardInEpubJumpHistory() { val target = epubJumpHistory.forwardLocator ?: return epubJumpHistory = epubJumpHistory.stepForward() navigateToEpubJumpLocator(target) } fun navigateToSearchResult(index: Int) { Timber.tag("NavDiag").d("navigateToSearchResult index: $index") val targetResult = searchState.searchResults.getOrNull(index) if (targetResult != null && currentRenderMode == RenderMode.VERTICAL_SCROLL) { recordEpubJump( SharedReaderLocator( chapterIndex = targetResult.locationInSource, cfi = "android-search:${targetResult.chunkIndex}:${targetResult.occurrenceIndexInLocation}", textQuote = targetResult.snippet.text ) ) } if (targetResult != null && currentRenderMode == RenderMode.PAGINATED) { scope.launch { searchState.currentSearchResultIndex = index isNavigatingToPosition = true try { val bookPaginator = paginator as? BookPaginator ?: return@launch val pageIdx = bookPaginator.findStablePageForSearchResult(targetResult) ?: return@launch Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx") val targetLocator = bookPaginator.getLocatorForPage(pageIdx) paginatedJumpLocatorForPage(pageIdx, targetLocator) ?.copy(textQuote = targetResult.snippet.text) ?.let { recordEpubJump(it) } scrollPaginatedToJumpPage(pageIdx, targetLocator) } finally { isNavigatingToPosition = false } } return } performSearchResultNavigation( index = index, searchState = searchState, renderMode = currentRenderMode, currentChapterIndex = currentChapterIndex, loadedChunkCount = loadedChunkCount, webView = webViewRefForTts, paginator = paginator, coroutineScope = scope, onVerticalChapterChange = { chapterIdx, chunkIdx, result -> Timber.tag("NavDiag").d("onVerticalChapterChange chapterIdx=$chapterIdx, chunkIdx=$chunkIdx, query=${result.query}") initialScrollTargetForChapter = null chunkTargetOverride = chunkIdx currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = chapterIdx searchHighlightTarget = result }, onVerticalScrollToResult = { result -> Timber.tag("NavDiag").d("onVerticalScrollToResult query=${result.query}, chunk=${result.chunkIndex}") val targetChunk = result.chunkIndex if (targetChunk >= loadedChunkCount) { val chunksToInject = (loadedChunkCount..targetChunk) chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } searchHighlightTarget = result }, onPaginatedScrollToPage = { pageIdx -> Timber.tag("NavDiag").d("onPaginatedScrollToPage pageIdx=$pageIdx") val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(pageIdx) paginatedJumpLocatorForPage(pageIdx, targetLocator) ?.copy(textQuote = searchState.searchResults.getOrNull(index)?.snippet?.text) ?.let { recordEpubJump(it) } scrollPaginatedToJumpPage(pageIdx, targetLocator) } ) } LaunchedEffect(paginatedPagerState.currentPage, currentRenderMode) { if (currentRenderMode == RenderMode.PAGINATED && volumeScrollEnabled) { delay(200) containerFocusRequester.requestFocus() Timber.d("Paginated: Page changed to ${paginatedPagerState.currentPage}, re-requesting focus for volume keys.") } } BackHandler(enabled = true) { if (drawerState.isOpen) { scope.launch { Timber.d("Back pressed: Closing drawer") drawerState.close() } } else if (isAutoScrollModeActive) { isAutoScrollModeActive = false isAutoScrollPlaying = false showBars = true } else if (searchState.isSearchActive) { searchState.isSearchActive = false searchState.onQueryChange("") } else { Timber.d("Back pressed: Navigating back. Position will be saved first.") triggerSaveAndExit() } } ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { EpubReaderDrawerSheet( chapters = chapters, tableOfContents = epubBook.tableOfContents, activeFragmentId = activeFragmentId, readerImages = readerImages, bookmarks = bookmarks, userHighlights = userHighlights, currentChapterIndex = currentChapterIndex, currentChapterInPaginatedMode = currentChapterInPaginatedMode, renderMode = currentRenderMode, activeHighlightPalette = currentHighlightPalette, onOpenPaletteManager = { showPaletteManager = true }, onHighlightColorChange = onHighlightColorChange, onNavigateToImage = { image -> scope.launch { drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { navigateVerticalToImage(image) } RenderMode.PAGINATED -> { val bookPaginator = paginator as? BookPaginator if (bookPaginator != null) { isNavigatingByToc = true try { val imagePage = bookPaginator.findStablePageForImageSource( chapterIndex = image.chapterIndex, sourcePath = image.sourcePath, elementId = image.elementId, ordinalInChapter = image.ordinalInChapter ) if (imagePage != null) { val (pageIndex, locator) = imagePage paginatedJumpLocatorForPage( pageIndex = pageIndex, targetLocator = locator, allowPageFallback = true )?.let { recordEpubJump(it) } scrollPaginatedToJumpPage(pageIndex, locator) } else { val fallbackPage = bookPaginator.findStableChapterStartPage(image.chapterIndex) if (fallbackPage != null) { recordEpubJump(chapterStartJumpLocator(image.chapterIndex).copy(pageIndex = fallbackPage)) scrollPaginatedToJumpPage( fallbackPage, Locator(image.chapterIndex, 0, 0), fallbackToChapterStart = true ) } } } finally { isNavigatingByToc = false } } } } if (showBars) showBars = false } }, onDownloadImage = { image -> pendingImageDownload = image imageSaveLauncher.launch(image.suggestedDownloadFileName()) }, onNavigateToTocEntry = { entry -> scope.launch { drawerState.close() val targetChapterIndex = chapters.indexOfFirst { it.absPath == entry.absolutePath } if (targetChapterIndex != -1) { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { recordEpubJump(fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath)) clearPendingTtsRelocationState("toc_entry_vertical") fragmentToLoad = entry.fragmentId if (targetChapterIndex != currentChapterIndex) { Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TOC_ENTRY, from=$currentChapterIndex, to=$targetChapterIndex, fragment='${entry.fragmentId}', label='${entry.label}'") initialScrollTargetForChapter = null currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = targetChapterIndex logTtsChapterDiag("Manual vertical chapter switch via TOC entry. targetChapter=$targetChapterIndex fragment=${entry.fragmentId}") } else { if (entry.fragmentId != null) { val js = """ (function() { var targetId = '${entry.fragmentId}'; var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); return -2; } if (window.virtualization && window.virtualization.chunksData) { for (var i = 0; i < window.virtualization.chunksData.length; i++) { var chunkHtml = window.virtualization.chunksData[i]; if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { return i; } } } return -1; })() """.trimIndent() webViewRefForTts?.evaluateJavascript(js) { result -> val chunkIdx = result?.toIntOrNull() ?: -1 if (chunkIdx >= 0) { if (chunkIdx >= loadedChunkCount) { val chunksToInject = (loadedChunkCount..chunkIdx) chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = chunkIdx loadedChunkCount = max(loadedChunkCount, chunkIdx + 1) } val scrollJs = """ (function() { var chunkIndex = $chunkIdx; var fragmentId = '${entry.fragmentId}'; var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); if (chunkDiv) { if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; chunkDiv.style.height = ""; } setTimeout(function() { var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } else { var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } }, 150); } })() """.trimIndent() webViewRefForTts?.evaluateJavascript(scrollJs, null) } else if (chunkIdx == -1) { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } } else { val bookPaginator = paginator as? BookPaginator if (bookPaginator != null) { Timber.tag("TOC_NAV_DEBUG").d("TOC Entry Clicked: ${entry.label}, targetChapter: $targetChapterIndex, anchor: ${entry.fragmentId}") isNavigatingByToc = true try { val targetPage = bookPaginator.findStablePageForAnchor(targetChapterIndex, entry.fragmentId) if (targetPage != null) { recordEpubJump( fragmentJumpLocator(targetChapterIndex, entry.fragmentId, entry.absolutePath) .copy(pageIndex = targetPage) ) Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TOC_ENTRY_PAGINATED, from=$currentChapterIndex, to=$targetChapterIndex, page=$targetPage, anchor='${entry.fragmentId}', label='${entry.label}'") Timber.tag("TOC_NAV_DEBUG").d("Scrolling Pager to page: $targetPage") val targetLocator = bookPaginator.getLocatorForPage(targetPage) ?: if (entry.fragmentId == null) Locator(targetChapterIndex, 0, 0) else null scrollPaginatedToJumpPage( targetPage, targetLocator, fallbackToChapterStart = entry.fragmentId == null ) } } finally { isNavigatingByToc = false } } else { Timber.tag("TOC_NAV_DEBUG").w("Paginator not ready for TOC navigation.") } } } else { Timber.w("TOC navigation failed: Could not find chapter for path ${entry.absolutePath}") } if (showBars) showBars = false } }, onNavigateToChapter = { index -> scope.launch { drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { if (index != currentChapterIndex) { recordEpubJump(chapterStartJumpLocator(index)) clearPendingTtsRelocationState("sidebar_chapter_vertical") Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER, from=$currentChapterIndex, to=$index") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = index logTtsChapterDiag("Manual vertical chapter switch via sidebar. targetChapter=$index") pullToNextProgress = 0f pullToPrevProgress = 0f if (showBars) showBars = false } } RenderMode.PAGINATED -> { val bookPaginator = paginator as? BookPaginator if (bookPaginator != null) { val currentFromPager = bookPaginator.findChapterIndexForPage(paginatedPagerState.currentPage) if (index != currentFromPager) { isNavigatingByToc = true try { val targetPage = bookPaginator.findStableChapterStartPage(index) if (targetPage != null) { recordEpubJump(chapterStartJumpLocator(index).copy(pageIndex = targetPage)) Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage") scrollPaginatedToJumpPage(targetPage, Locator(index, 0, 0), fallbackToChapterStart = true) if (showBars) showBars = false } } finally { isNavigatingByToc = false } } } } } } }, onNavigateToBookmark = { bookmark -> scope.launch { drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi) val targetChunk = locator?.let { it.blockIndex / 20 } if (bookmark.chapterIndex != currentChapterIndex) { Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=BOOKMARK, from=$currentChapterIndex, to=${bookmark.chapterIndex}, cfi='${bookmark.cfi}', label='${bookmark.label}'") chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) { targetChunk } else { 0 } currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = bookmark.chapterIndex } else { if (targetChunk != null && targetChunk >= 0) { isNavigatingToPosition = true if (targetChunk >= loadedChunkCount) { Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk") val chunksToInject = (loadedChunkCount..targetChunk) chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } else { val content = chapterChunks.getOrNull(targetChunk) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($targetChunk, '$escaped');", null ) } } webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null ) scope.launch { delay(3000) if (isNavigatingToPosition) { isNavigatingToPosition = false } } } else { // Fallback if we couldn't determine chunk webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null ) } } } RenderMode.PAGINATED -> { recordEpubJump(cfiJumpLocator(bookmark.chapterIndex, bookmark.cfi, bookmark.snippet)) Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") isNavigatingToPosition = true try { val bookPaginator = paginator as? BookPaginator val locator = locatorConverter.getLocatorFromCfi( book = epubBook, chapterIndex = bookmark.chapterIndex, cfi = bookmark.cfi ) if (locator != null && bookPaginator != null) { Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator") val pageIndex = bookPaginator.findStablePageForLocator(locator) if (pageIndex != null) { Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.") scrollPaginatedToJumpPage(pageIndex, locator) } else { Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.") val chapterStartPage = bookPaginator.findStableChapterStartPage(bookmark.chapterIndex) if (chapterStartPage != null) { scrollPaginatedToJumpPage(chapterStartPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true) } } } else { Timber.w("P-Mode Click: Failed to convert CFI to Locator. Falling back to stable chapter start.") val fallbackPage = bookPaginator?.findStableChapterStartPage(bookmark.chapterIndex) if (fallbackPage != null) { scrollPaginatedToJumpPage(fallbackPage, Locator(bookmark.chapterIndex, 0, 0), fallbackToChapterStart = true) } } } finally { isNavigatingToPosition = false } } } if (showBars) { showBars = false } } }, onNavigateToHighlight = { highlight -> scope.launch { drawerState.close() when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) cfiToLoad = highlight.cfi val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) val targetChunk = locator?.let { it.blockIndex / 20 } if (highlight.chapterIndex != currentChapterIndex) { Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=HIGHLIGHT, from=$currentChapterIndex, to=${highlight.chapterIndex}, cfi='${highlight.cfi}'") chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0 currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = highlight.chapterIndex } else { if (targetChunk != null && targetChunk >= 0) { isNavigatingToPosition = true if (targetChunk >= loadedChunkCount) { val chunksToInject = (loadedChunkCount..targetChunk) chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } else { val content = chapterChunks.getOrNull(targetChunk) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($targetChunk, '$escaped');", null ) } } webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');", null ) scope.launch { delay(3000) if (isNavigatingToPosition) { isNavigatingToPosition = false } } } else { webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');", null ) } } } RenderMode.PAGINATED -> { recordEpubJump(cfiJumpLocator(highlight.chapterIndex, highlight.cfi, highlight.text)) isNavigatingToPosition = true try { val bookPaginator = paginator as? BookPaginator val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) if (locator != null && bookPaginator != null) { val pageIndex = bookPaginator.findStablePageForLocator(locator) if (pageIndex != null) { scrollPaginatedToJumpPage(pageIndex, locator) } else { val chapterStartPage = bookPaginator.findStableChapterStartPage(highlight.chapterIndex) if (chapterStartPage != null) { scrollPaginatedToJumpPage(chapterStartPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true) } } } else { val fallbackPage = bookPaginator?.findStableChapterStartPage(highlight.chapterIndex) if (fallbackPage != null) { scrollPaginatedToJumpPage(fallbackPage, Locator(highlight.chapterIndex, 0, 0), fallbackToChapterStart = true) } } } finally { isNavigatingToPosition = false } } } if (showBars) showBars = false } }, onDeleteBookmark = { bookmarkToDelete -> bookmarks = bookmarks - bookmarkToDelete }, onRenameBookmark = { bookmark, newLabel -> bookmarks = bookmarks.map { if (it.cfi == bookmark.cfi) it.copy(label = newLabel) else it }.toSet() }, onDeleteHighlight = { highlightToDelete -> userHighlights.remove(highlightToDelete) if (currentRenderMode == RenderMode.VERTICAL_SCROLL && highlightToDelete.chapterIndex == currentChapterIndex) { val cssClass = highlightToDelete.color.cssClass val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(highlightToDelete.cfi)}', '$cssClass');" Timber.d("Executing JS removal for highlight: ${highlightToDelete.cfi}") webViewRefForTts?.evaluateJavascript(jsCommand, null) } }, onEditNote = { highlight -> highlightToNoteCfi = highlight.cfi }, ) } ) { val isTtsSessionActive = (ttsState.currentText != null || ttsState.isLoading) && ttsState.playbackSource == "READER" val audioManager = remember(context) { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } var isMusicActive by remember { mutableStateOf(audioManager.isMusicActive) } LaunchedEffect(Unit) { while(isActive) { val currentlyActive = audioManager.isMusicActive if (isMusicActive != currentlyActive) { isMusicActive = currentlyActive Timber.d("isMusicActive changed to: $isMusicActive") } delay(1000) } } volumeScrollEnabled && currentRenderMode == RenderMode.VERTICAL_SCROLL && !isTtsSessionActive && !isMusicActive LaunchedEffect(Unit) { containerFocusRequester.requestFocus() } LaunchedEffect(volumeScrollEnabled) { if (volumeScrollEnabled) { containerFocusRequester.requestFocus() Timber.d("Volume scroll enabled. Re-requesting focus on the reader container.") } } val handleGenerateSummary: (Boolean) -> Unit = { force -> if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) { showInsufficientCreditsDialog = true showAiHubSheet = false } else { showAiHubSheet = true isSummarizationLoading = true summarizationResult = null when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { val cached = if (!force) summaryCacheManager.getSummary( epubBook.title, currentChapterIndex ) else null if (cached != null) { summarizationResult = SummarizationResult(summary = cached, isCacheHit = true) isSummarizationLoading = false } else { webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> Timber.d("JS summarization request: $result") } ?: run { isSummarizationLoading = false summarizationResult = SummarizationResult(error = context.getString(R.string.error_webview_not_available)) } } } RenderMode.PAGINATED -> { scope.launch { val currentPage = paginatedPagerState.currentPage val token = viewModel.getAuthToken() val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage) Timber.tag("POS_DIAG") .d("handleGenerateSummary (Paginated): currentPage=$currentPage -> resolved chapterIndex=$chapterIndex") if (chapterIndex != null) { val cached = if (!force) summaryCacheManager.getSummary( epubBook.title, chapterIndex ) else null if (cached != null) { summarizationResult = SummarizationResult(summary = cached, isCacheHit = true) isSummarizationLoading = false return@launch } val text = paginator?.getPlainTextForChapter(chapterIndex) if (!text.isNullOrBlank()) { var currentCost: Double? = null var currentFreeRemaining: Int? = null val finalSummaryBuilder = StringBuilder() summarizeBookContent( content = text, context = context, authToken = token, onUsageReceived = { cost, freeRemaining -> currentCost = cost currentFreeRemaining = freeRemaining summarizationResult = summarizationResult?.copy( cost = cost, freeRemaining = freeRemaining ) ?: SummarizationResult( cost = cost, freeRemaining = freeRemaining ) }, onUpdate = { chunk -> finalSummaryBuilder.append(chunk) val currentSummary = summarizationResult?.summary ?: "" summarizationResult = SummarizationResult( summary = currentSummary + chunk, cost = currentCost, freeRemaining = currentFreeRemaining ) }, onError = { error -> if (error == "INSUFFICIENT_CREDITS") { showInsufficientCreditsDialog = true showAiHubSheet = false isSummarizationLoading = false } else { summarizationResult = SummarizationResult(error = error) } }, onFinish = { isSummarizationLoading = false val fullSummary = finalSummaryBuilder.toString() if (fullSummary.isNotBlank()) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title ?: context.getString(R.string.chapter_number_format, chapterIndex + 1) summaryCacheManager.saveSummary( epubBook.title, chapterIndex, chapterTitle, fullSummary ) } }) } else { summarizationResult = SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content)) isSummarizationLoading = false } } else { summarizationResult = SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter)) isSummarizationLoading = false } } } } } } val handleGenerateRecap: () -> Unit = { if (BuildConfig.FLAVOR != "oss" && credits <= 0) { showInsufficientCreditsDialog = true showAiHubSheet = false } else { showAiHubSheet = true when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { isRequestingRecapCfi = true webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null ) } RenderMode.PAGINATED -> { val bookPaginator = paginator as? BookPaginator val chapterIndex = currentChapterInPaginatedMode if (bookPaginator != null && chapterIndex != null) { val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val currentPageInChapter = paginatedPagerState.currentPage - startPage val charsScrolled = bookPaginator.getCharactersScrolledInChapter( chapterIndex, currentPageInChapter ) runRecap(chapterIndex, charsScrolled.toInt()) } else { showBanner("Wait for book to load fully.", isError = true) } } } } } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, contentWindowInsets = WindowInsets.statusBars, ) { scaffoldPaddingValues -> val currentTopPadding = scaffoldPaddingValues.calculateTopPadding() var stableTopPadding by remember { mutableStateOf(0.dp) } if (currentTopPadding > stableTopPadding) { stableTopPadding = currentTopPadding } val effectiveTopPadding = if (currentRenderMode == RenderMode.PAGINATED) { if (systemUiMode == SystemUiMode.HIDDEN) { 0.dp } else { val insets = ViewCompat.getRootWindowInsets(view) val ignoringVisibilityTopPx = insets?.getInsetsIgnoringVisibility( WindowInsetsCompat.Type.statusBars())?.top ?: 0 val ignoringVisibilityTop = with(density) { ignoringVisibilityTopPx.toDp() } if (ignoringVisibilityTop > 0.dp) { ignoringVisibilityTop } else if (stableTopPadding > 0.dp) { stableTopPadding } else { 24.dp } } } else { currentTopPadding } val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel() val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel() val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null) Box( modifier = Modifier .fillMaxSize() .background(effectiveBg) .then(activeTextureModifier) .padding(top = effectiveTopPadding) .focusRequester(containerFocusRequester) .focusable() .volumeScrollHandler( volumeScrollEnabled = volumeScrollEnabled, renderMode = currentRenderMode, isTtsActive = isTtsSessionActive, isMusicActive = isMusicActive, currentScrollY = currentScrollYPosition, currentScrollHeight = currentScrollHeightValue, currentClientHeight = currentClientHeightValue, currentChapterIndex = currentChapterIndex, totalChapters = chapters.size, onScrollBy = { amount -> webViewRefForTts?.evaluateJavascript( "window.scrollBy({ top: $amount, behavior: 'smooth' });", null ) }, onNavigateChapter = { offset, target -> scope.launch { clearPendingTtsRelocationState("manual_chapter_change") initialScrollTargetForChapter = target currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex += offset logTtsChapterDiag( "Manual vertical chapter switch via volume/button nav. " + "offset=$offset target=$target newChapter=$currentChapterIndex" ) } }, onNextPage = { scope.launch { val pageCount = paginatedPagerState.pageCount if (pageCount > 0) { val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1) if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) } else paginatedPagerState.scrollToPage(targetPage) } } } }, onPrevPage = { scope.launch { val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0) if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) } else paginatedPagerState.scrollToPage(targetPage) } } } ) ) { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp Box( modifier = Modifier .fillMaxSize() .padding(top = contentTopPadding) .padding(bottom = contentBottomPadding) .testTag("ReaderContainer") ) { if (chapters.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(stringResource(R.string.no_chapters_available)) } } else { AnimatedContent( targetState = currentChapterIndex, transitionSpec = { if (!pullToTurnEnabled) { fadeIn(animationSpec = tween(150)) togetherWith fadeOut(animationSpec = tween(150)) } else { if (targetState > initialState) { (slideInVertically { height -> height } + fadeIn()) .togetherWith(slideOutVertically { height -> -height } + fadeOut()) } else { (slideInVertically { height -> -height } + fadeIn()) .togetherWith(slideOutVertically { height -> height } + fadeOut()) } } }, label = "ChapterChangeAnimation", modifier = Modifier.fillMaxSize() ) { targetChapterIndex -> if (isChapterParsing) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } else if (chapterChunks.isNotEmpty()) { var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) } val initialContentToLoad = remember( loadUpToChunkIndex, chapterChunks, chapterChunkElementStartIndices, chapterChunkElementCounts ) { val targetIdx = loadUpToChunkIndex val startIdx = 0 val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) chapterChunks.indices.joinToString(separator = "\n") { index -> val attributes = readerChunkContainerAttributes( index, chapterChunkElementStartIndices, chapterChunkElementCounts ) if (index in startIdx..endIdx) { "
${chapterChunks[index]}
" } else { "
" } } } val initialHtml = """ $chapterHead
$initialContentToLoad
""".trimIndent() val chapterToRender = chapters[targetChapterIndex] fun isCurrentRenderedChapter(): Boolean = targetChapterIndex == currentChapterIndex val chapterKeyForWebView = remember( chapterToRender.htmlFilePath, epubBook.extractionBasePath ) { "${epubBook.extractionBasePath}/${chapterToRender.htmlFilePath}" } val chapterDirectoryPath = chapterToRender.htmlFilePath.substringBeforeLast( '/', "" ) val baseUrl = "file://${epubBook.extractionBasePath}/$chapterDirectoryPath/" val topPaddingPx = with(LocalDensity.current) { 16.dp.toPx() } var isWebViewReady by remember(chapterKeyForWebView) { mutableStateOf( false ) } LaunchedEffect(isWebViewReady, searchHighlightTarget) { val target = searchHighlightTarget Timber.tag("NavDiag").d("Effect(isWebViewReady=$isWebViewReady, target=$target) triggered for chapter $targetChapterIndex.") if (isWebViewReady && target != null && target.locationInSource == targetChapterIndex) { Timber.tag("NavDiag").d("Highlighting condition met. Highlighting now.") delay(200) val webView = webViewRefForTts if (webView != null) { val escapedQuery = escapeJsString(target.query) val targetChunk = target.chunkIndex val relativeIdx = searchState.searchResults .filter { it.locationInSource == target.locationInSource && it.chunkIndex == targetChunk } .indexOf(target) .coerceAtLeast(0) val js = "javascript:console.log('NavDiag: Executing robust search highlight JS'); window.CURRENT_SEARCH_QUERY = '${escapedQuery}'; window.highlightAllOccurrences('${escapedQuery}'); window.scrollToChunkOccurrence($targetChunk, $relativeIdx);" Timber.tag("NavDiag").d("Executing search highlight/scroll JS: $js") webView.evaluateJavascript(js) { result -> Timber.tag("NavDiag").d("JS highlight/scroll result: $result") } searchHighlightTarget = null } else { Timber.tag("NavDiag").w("Highlight failed: WebView was null even after ready signal.") searchHighlightTarget = null } } } val currentChapterTocFragments = remember(epubBook.tableOfContents, targetChapterIndex) { val chapterPath = chapters.getOrNull(targetChapterIndex)?.absPath epubBook.tableOfContents .filter { it.absolutePath == chapterPath && it.fragmentId != null } .mapNotNull { it.fragmentId } } @Suppress("ControlFlowWithEmptyBody") ChapterWebView( key = chapterKeyForWebView, chapterTitle = chapterToRender.title, isDarkTheme = isDarkTheme, effectiveBg = effectiveBg, effectiveText = effectiveText, initialScrollTarget = initialScrollTargetForChapter, initialPageScrollY = currentScrollYPosition, initialCfi = cfiToLoad, initialFragmentId = fragmentToLoad.also { }, initialImageSource = imageToLoad?.sourcePath, initialImageOriginalSource = imageToLoad?.originalSource, initialImageOrdinal = imageToLoad?.ordinalInChapter ?: 0, userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex }, activeHighlightPalette = currentHighlightPalette, onUpdatePalette = onUpdateHighlightPalette, onHighlightCreated = { cfi, text, colorId -> Timber.d("Vertical Mode (Source): Creating Highlight. CFI: $cfi") Timber.d("Vertical Mode (Source): Text Snippet: '${text.take(50)}...'") val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW val finalCfi = processAndAddHighlight( newCfi = cfi, newText = text, newColor = color, chapterIndex = currentChapterIndex, currentList = userHighlights ) if (pendingNoteForNewHighlight) { pendingNoteForNewHighlight = false highlightToNoteCfi = finalCfi } }, onNoteRequested = { cfi -> if (cfi != null) { highlightToNoteCfi = cfi } else { pendingNoteForNewHighlight = true } }, onHighlightDeleted = { cfi -> val toRemove = userHighlights.find { it.cfi == cfi } if (toRemove != null) { userHighlights.remove(toRemove) Timber.d("Deleted highlight: $cfi") } }, onChapterInitiallyScrolled = { if (!isCurrentRenderedChapter()) { Timber.tag(TAG_VERTICAL_JITTER).d( "ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'" ) } else { val wasCfiScroll = cfiToLoad != null val wasImageScroll = imageToLoad != null Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll") logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll wasImageScroll=$wasImageScroll") initialScrollTargetForChapter = null cfiToLoad = null fragmentToLoad = null imageToLoad = null Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll") isWebViewReady = true if (wasCfiScroll) { scope.launch { delay(1000L) isChapterReadyForBookmarkCheck = true Timber.d("Auto-save enabled after CFI scroll delay.") } } else { isChapterReadyForBookmarkCheck = true Timber.d("Auto-save enabled immediately.") } if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) { Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") logTtsChapterDiag("Auto-starting TTS extraction for chapter load") hasRequestedExtractionForThisChapter = true scope.launch { delay(200) webViewRefForTts?.evaluateJavascript( "javascript:TtsBridgeHelper.extractAndRelayText();", null ) } } if (isAutoScrollModeActive && isAutoScrollPlaying) { Timber.d("Continuing Auto-Scroll for new chapter with delay.") triggerAutoScrollTempPause(1000L) } } }, onTap = { if (isAutoScrollModeActive) { isAutoScrollPlaying = !isAutoScrollPlaying Timber.d("Auto-scroll toggled via tap: $isAutoScrollPlaying") } if (!(isMusicianMode && isAutoScrollModeActive) && System.currentTimeMillis() - lastHighlightClickTime > 500) { focusManager.clearFocus() if (volumeScrollEnabled && !searchState.isSearchActive) { containerFocusRequester.requestFocus() } if (System.currentTimeMillis() - lastScrollHideTime < 400) { Timber.d("Ignoring tap toggle because bars were just hidden by scroll (sloppy tap).") } else { if (showBars || showFormatAdjustmentBars) { showBars = false showFormatAdjustmentBars = false Timber.d("Chapter tapped, hiding all bars.") } else { showBars = true Timber.d("Chapter tapped, showing main bars.") } } } }, onPotentialScroll = { if (showBars || showFormatAdjustmentBars) { showBars = false showFormatAdjustmentBars = false lastScrollHideTime = System.currentTimeMillis() // Added Timber.d("Scroll/Drag detected, hiding bars.") } if (isAutoScrollModeActive && isAutoScrollPlaying) { triggerAutoScrollTempPause(300L) } }, onAutoScrollChapterEnd = { Timber.d("Screen: onAutoScrollChapterEnd triggered. Current Index: $currentChapterIndex") scope.launch { if (currentChapterIndex < chapters.size - 1) { clearPendingTtsRelocationState("auto_scroll_chapter_end") Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=AUTO_SCROLL_END, from=$currentChapterIndex, to=${currentChapterIndex + 1}") Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex++ logTtsChapterDiag("Auto-scroll moved vertical reader to next chapter. newChapter=$currentChapterIndex") isAutoScrollPlaying = true } else { Timber.d("Screen: Reached end of book. Stopping auto-scroll.") isAutoScrollPlaying = false } } }, onOverScrollTop = { dragAmount -> if (pullToTurnEnabled) { if (targetChapterIndex > 0) { pullToPrevProgress = dragAmount / dragThresholdPx } } else { if (targetChapterIndex > 0 && dragAmount > 20f && !isSeamlessTransitioning) { isSeamlessTransitioning = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) scope.launch { clearPendingTtsRelocationState("overscroll_top_seamless") delay(20) initialScrollTargetForChapter = ChapterScrollPosition.END currentScrollYPosition = 0 currentScrollHeightValue = 0 Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=OVERSCROLL_TOP_SEAMLESS, from=$targetChapterIndex, to=${targetChapterIndex - 1}") currentChapterIndex-- logTtsChapterDiag("Seamless overscroll moved to previous chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false delay(300) isSeamlessTransitioning = false } } } }, onOverScrollBottom = { dragAmount -> if (pullToTurnEnabled) { if (targetChapterIndex < chapters.size - 1) { pullToNextProgress = dragAmount / dragThresholdPx } } else { if (targetChapterIndex < chapters.size - 1 && dragAmount > 20f && !isSeamlessTransitioning) { isSeamlessTransitioning = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) scope.launch { clearPendingTtsRelocationState("overscroll_bottom_seamless") delay(20) initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=OVERSCROLL_BOTTOM_SEAMLESS, from=$targetChapterIndex, to=${targetChapterIndex + 1}") currentChapterIndex++ logTtsChapterDiag("Seamless overscroll moved to next chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false delay(300) isSeamlessTransitioning = false } } } }, onReleaseOverScrollTop = { if (pullToTurnEnabled && targetChapterIndex > 0 && pullToPrevProgress >= 1.0f) { Timber.d("Swipe-up triggered. Saving position before changing to previous chapter." ) webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null ) scope.launch { clearPendingTtsRelocationState("pull_to_turn_prev") if (isActiveReaderTtsForCurrentBook()) { detachVerticalReaderFromTts("pull_to_turn_prev") } delay(50) initialScrollTargetForChapter = ChapterScrollPosition.END currentScrollYPosition = 0 currentScrollHeightValue = 0 Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=PULL_TO_TURN_PREV, from=$targetChapterIndex, to=${targetChapterIndex - 1}") currentChapterIndex-- logTtsChapterDiag("Pull-to-turn moved to previous chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END") } } pullToPrevProgress = 0f }, onReleaseOverScrollBottom = { if (pullToTurnEnabled && targetChapterIndex < chapters.size - 1 && pullToNextProgress >= 1.0f) { Timber.d("Swipe-down triggered. Saving position before changing to next chapter." ) webViewRefForTts?.evaluateJavascript( "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null ) scope.launch { clearPendingTtsRelocationState("pull_to_turn_next") if (isActiveReaderTtsForCurrentBook()) { detachVerticalReaderFromTts("pull_to_turn_next") } delay(50) initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=PULL_TO_TURN_NEXT, from=$targetChapterIndex, to=${targetChapterIndex + 1}") currentChapterIndex++ logTtsChapterDiag("Pull-to-turn moved to next chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false } } pullToNextProgress = 0f }, tocFragments = currentChapterTocFragments, onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId -> if (!isCurrentRenderedChapter()) { Timber.tag(TAG_VERTICAL_JITTER).d( "ignored stale scrollState rendered=$targetChapterIndex current=$currentChapterIndex y=$scrollY height=$scrollHeight chapter='${chapterToRender.title}'" ) } else { currentScrollYPosition = scrollY currentScrollHeightValue = scrollHeight currentClientHeightValue = clientHeight if (activeFragmentId != fragId) { Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId") activeFragmentId = fragId } if (volumeScrollEnabled && !searchState.isSearchActive) { volumeScrollFocusDebounceJob.value?.cancel() volumeScrollFocusDebounceJob.value = scope.launch { delay(300L) if (isActive) { containerFocusRequester.requestFocus() Timber.d("Refocusing container after scroll to re-enable volume keys.") } } } } }, modifier = Modifier.fillMaxSize(), currentFontSize = currentFontSizeEm, currentLineHeight = currentLineHeight, currentParagraphGap = currentParagraphGap, currentImageSize = currentImageSize, currentHorizontalMargin = currentHorizontalMargin, currentVerticalMargin = currentVerticalMargin, currentFontFamily = currentFontFamily, customFontPath = currentCustomFontPath, currentTextAlign = currentTextAlign, activeTextureId = activeTextureId, activeTextureAlpha = activeTextureAlpha, onHighlightClicked = { lastHighlightClickTime = System.currentTimeMillis() showBars = false showFormatAdjustmentBars = false Timber.d("Highlight clicked - Forcing bars hidden") }, onInternalLinkClick = { url -> scope.launch { val basePath = "file://${epubBook.extractionBasePath}/" val rawRelativeUrl = url.removePrefix(basePath) val relativeUrl = if (rawRelativeUrl != url) { rawRelativeUrl } else { val decodedUrl = try { java.net.URLDecoder.decode(url, "UTF-8") } catch (_: Exception) { url } decodedUrl.removePrefix(basePath) } val pathPart = relativeUrl.substringBefore('#') val fragmentPart = relativeUrl.substringAfter('#', "").takeIf { it.isNotEmpty() } val decodedPath = try { java.net.URLDecoder.decode(pathPart, "UTF-8") } catch(e: Exception) { pathPart } val renderedChapter = chapters.getOrNull(targetChapterIndex) val renderedChapterDirectory = renderedChapter ?.htmlFilePath ?.substringBeforeLast('/', "") .orEmpty() .trim('/') val decodedPathDirectory = decodedPath.trim('/') val resolvedTargetChapterIndex = when { pathPart.isBlank() -> targetChapterIndex decodedPath.isBlank() -> targetChapterIndex renderedChapterDirectory.isNotBlank() && decodedPathDirectory == renderedChapterDirectory -> targetChapterIndex else -> chapters.indexOfFirst { it.absPath == decodedPath || it.htmlFilePath == decodedPath || it.absPath.trim('/') == decodedPathDirectory || it.htmlFilePath.trim('/') == decodedPathDirectory } } Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> url: $url") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> basePath: $basePath") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> relativeUrl: $relativeUrl") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> pathPart: $pathPart") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> decodedPath: $decodedPath") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> fragmentPart: $fragmentPart") Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $resolvedTargetChapterIndex (current is $currentChapterIndex)") if (resolvedTargetChapterIndex != -1) { recordEpubJump(fragmentJumpLocator(resolvedTargetChapterIndex, fragmentPart, chapters.getOrNull(resolvedTargetChapterIndex)?.absPath ?: decodedPath)) if (resolvedTargetChapterIndex != currentChapterIndex) { Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$resolvedTargetChapterIndex, fragment='$fragmentPart'") initialScrollTargetForChapter = null fragmentToLoad = fragmentPart currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = resolvedTargetChapterIndex } else { Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> Target is current chapter. Evaluating JS for fragment.") if (fragmentPart != null) { val escapedFragment = escapeJsString(fragmentPart) val js = """ (function() { var targetId = '$escapedFragment'; var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); return -2; } if (window.virtualization && window.virtualization.chunksData) { for (var i = 0; i < window.virtualization.chunksData.length; i++) { var chunkHtml = window.virtualization.chunksData[i]; if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { return i; } } } return -1; })() """.trimIndent() webViewRefForTts?.evaluateJavascript(js) { result -> val chunkIdx = result?.toIntOrNull() ?: -1 if (chunkIdx >= 0) { if (chunkIdx >= loadedChunkCount) { val chunksToInject = (loadedChunkCount..chunkIdx) chunksToInject.forEach { idx -> val content = chapterChunks.getOrNull(idx) if (content != null) { val escaped = escapeJsString(content) webViewRefForTts?.evaluateJavascript( "javascript:window.virtualization.appendChunk($idx, '$escaped');", null ) } } loadUpToChunkIndex = chunkIdx loadedChunkCount = max(loadedChunkCount, chunkIdx + 1) } val scrollJs = """ (function() { var chunkIndex = $chunkIdx; var fragmentId = '$escapedFragment'; var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); if (chunkDiv) { if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; chunkDiv.style.height = ""; } setTimeout(function() { var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } else { var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } }, 150); } })() """.trimIndent() webViewRefForTts?.evaluateJavascript(scrollJs, null) } else if (chunkIdx == -1) { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } } if (showBars) showBars = false } else { Timber.tag(TAG_LINK_NAV).w("Could not find chapter for internal link: $url") } } }, onWebViewInstanceCreated = { webView -> if (isCurrentRenderedChapter()) { webViewRefForTts = webView } else { Timber.tag(TAG_VERTICAL_JITTER).d( "ignored stale webViewRef rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'" ) } webView.evaluateJavascript( "javascript:window.setViewportPadding(${topPaddingPx}, 0);", null ) }, onWebViewDisposed = { webView -> if (webViewRefForTts === webView) { webViewRefForTts = null } }, onScrollFinished = { success -> Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success") isNavigatingToPosition = false }, ttsScope = scope, onTtsTextReady = { jsonString -> scope.launch { val token = viewModel.getAuthToken() Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") val ttsChunks = mutableListOf() try { val jsonArray = JSONArray(jsonString) for (i in 0 until jsonArray.length()) { val jsonObject = jsonArray.getJSONObject(i) val text = jsonObject.getString("text") val cfiJsonObject = JSONObject(jsonObject.getString("cfi")) val cfi = cfiJsonObject.getString("cfi") Timber.tag("TTS_LIST_DIAG").d("Processing Chunk[$i]: text='${text.take(40)}...' cfi='$cfi'") val baseOffset = jsonObject.optInt("startOffset", 0) val subChunks = splitTextIntoChunks(text) var currentOffset = baseOffset for (subChunk in subChunks) { ttsChunks.add( TtsChunk( text = subChunk, sourceCfi = cfi, startOffsetInSource = currentOffset ) ) currentOffset += subChunk.length } } } catch (e: Exception) { Timber.e(e, "Vertical: JSON parsing failed") } Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") logTtsChapterDiag( "Vertical TTS text ready. targetChapter=$targetChapterIndex " + "chunkCount=${ttsChunks.size} visibleChapter=$currentChapterIndex" ) if (ttsChunks.isNotEmpty()) { logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex") if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true ttsShouldStartOnChapterLoad = false return@launch } ttsShouldStartOnChapterLoad = false userStoppedTts = false val chapterTitle = chapters.getOrNull(targetChapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsChapterIndex = targetChapterIndex val nativeChapterChunks = locatorConverter .getTtsChunksForChapter(epubBook, targetChapterIndex, bookId) .orEmpty() val extractedStartChunk = ttsChunks.firstOrNull() val nativeStartChunkIndex = findTtsChunkStartIndex(nativeChapterChunks, extractedStartChunk) val sessionChunks = if (nativeChapterChunks.isNotEmpty() && nativeStartChunkIndex != null) { nativeChapterChunks.withInitialChunkOverride(nativeStartChunkIndex, extractedStartChunk) } else { ttsChunks } val startChunkIndex = nativeStartChunkIndex ?: 0 ttsController.start( chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, bookId = bookId, chapterIndex = targetChapterIndex, totalChapters = chapters.size, startChunkIndex = startChunkIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token ) } else { Timber.w("No TTS chunks were created from JSON, not starting TTS.") logTtsChapterDiag("Vertical TTS extraction produced 0 chunks for chapter $targetChapterIndex") if (ttsShouldStartOnChapterLoad) { Timber.d("Empty chapter detected during start. Advancing UI to next chapter.") val nextIdx = targetChapterIndex + 1 if (nextIdx < chapters.size) { Timber.tag(TAG_LINK_NAV) .d("[CHAPTER-NAV] source=TTS_EMPTY_CHAPTER_SKIP, from=$targetChapterIndex, to=$nextIdx") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = nextIdx } } } } }, onWordSelectedForAiDefinition = { text -> onDictionaryLookup(text) }, onTranslate = { text -> onTranslateLookup(text) }, onSearch = { text -> onSearchLookup(text) }, onContentReadyForSummarization = { content -> Timber.d("Content received for summarization") scope.launch { val token = viewModel.getAuthToken() val chapterIndexToSave = currentChapterIndex val bookTitleToSave = epubBook.title val finalSummaryBuilder = StringBuilder() var currentCost: Double? = null var currentFreeRemaining: Int? = null summarizeBookContent( content = content, context = context, authToken = token, onUsageReceived = { cost: Double?, freeRemaining: Int? -> currentCost = cost currentFreeRemaining = freeRemaining summarizationResult = summarizationResult?.copy( cost = cost, freeRemaining = freeRemaining ) ?: SummarizationResult(cost = cost, freeRemaining = freeRemaining) }, onUpdate = { chunk -> finalSummaryBuilder.append(chunk) val currentSummary = summarizationResult?.summary ?: "" summarizationResult = SummarizationResult( summary = currentSummary + chunk, cost = currentCost, freeRemaining = currentFreeRemaining ) }, onError = { error -> if (error == "INSUFFICIENT_CREDITS") { showInsufficientCreditsDialog = true showAiHubSheet = false isRecapLoading = false } else { recapResult = SummarizationResult(error = error) } }, onFinish = { isSummarizationLoading = false val fullSummary = finalSummaryBuilder.toString() if (fullSummary.isNotBlank()) { val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: context.getString(R.string.chapter_number_format, chapterIndexToSave + 1) summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary) } } ) } }, onFootnoteRequested = { html -> activeFootnoteHtml = html }, isProUser = isProUser, isOss = BuildConfig.FLAVOR == "oss", onShowDictionaryUpsellDialog = { showDictionaryUpsellDialog = true }, onCfiGenerated = { cfi -> Timber.tag("PosSaveDiag").d("JS generated CFI string: '$cfi'") if (cfi.isBlank() || !cfi.startsWith('/')) { if (isSavingAndExiting) { isSavingAndExiting = false onNavigateBack() } return@ChapterWebView } scope.launch { val locator = locatorConverter.getLocatorFromCfi( epubBook, latestChapterIndex, cfi ) if (locator != null) { Timber.tag("PosSaveDiag").d("✅ Converted CFI to Locator successfully: chapter=${locator.chapterIndex}, block=${locator.blockIndex}, charOffset=${locator.charOffset}") lastKnownLocator = locator val progressWithinChapter = if (currentScrollHeightValue > currentClientHeightValue) { val scrollableHeight = (currentScrollHeightValue - currentClientHeightValue).toFloat() if (scrollableHeight > 0) (currentScrollYPosition.toFloat() / scrollableHeight).coerceIn( 0f, 1f ) else 1f } else if (currentScrollHeightValue > 0) { 1f } else { 0f } val currentChapterLengthChars = chapters.getOrNull( latestChapterIndex )?.plainTextContent?.length?.toLong() ?: 0L // Handle Recap Request INTERCEPTION if (isRequestingRecapCfi) { Timber.d("Vertical Mode: Received CFI: $cfi") isRequestingRecapCfi = false // Use exact text offset from Locator if available val exactOffset = locatorConverter.getTextOffset(epubBook, locator) val charLimit = if (exactOffset != null) { Timber.d("Vertical Mode: Using exact text offset from Locator: $exactOffset") exactOffset } else { Timber.w("Vertical Mode: Could not calculate exact offset. Falling back to scroll percentage.") (currentChapterLengthChars * progressWithinChapter).toInt() } Timber.d("Vertical Mode: Final CharLimit: $charLimit (Total Chapter Chars: $currentChapterLengthChars)") runRecap(latestChapterIndex, charLimit) return@launch } // Continue with Save Logic val progress = if (totalBookLengthChars > 0) { val completedCharsInPreviousChapters = chapters.take(latestChapterIndex) .sumOf { it.plainTextContent.length.toLong() } val charsScrolledInCurrentChapter = (progressWithinChapter * currentChapterLengthChars).toLong() val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() val isLastChapter = latestChapterIndex == chapters.size - 1 val isAtEndOfBook = isLastChapter && (currentScrollYPosition + currentClientHeightValue) >= (currentScrollHeightValue - 2) if (isAtEndOfBook) 100f else calculatedProgress } else { 0f } Timber.tag("POS_DIAG").i("Saving Position: Chapter=$latestChapterIndex, CFI=$cfi, Progress=$progress%") onSavePosition(locator, cfi, progress) } else { Timber.w("Failed to convert CFI to Locator: $cfi." ) } if (isSwitchingToPaginated) { isSwitchingToPaginated = false chapterToLoadOnSwitch = latestChapterIndex isPagerInitialized = false Timber.d("V->P: Locator generated (success=${locator != null}). Switching to paginated mode for chapter $chapterToLoadOnSwitch." ) currentRenderMode = RenderMode.PAGINATED onRenderModeChange(RenderMode.PAGINATED) } if (isSavingAndExiting) { Timber.d("Save attempt complete, now navigating back." ) isSavingAndExiting = false onNavigateBack() } } }, onBookmarkCfiGenerated = { cfi -> if (addBookmarkRequest) { Timber.d("Vertical add: CFI received: $cfi. Now requesting snippet." ) scope.launch { val jsToExecute = "javascript:SnippetBridge.onSnippetExtracted('${ escapeJsString(cfi) }', window.getSnippetForCfi('${ escapeJsString( cfi ) }'));" Timber.d("Executing JS for snippet: $jsToExecute" ) webViewRefForTts?.evaluateJavascript( jsToExecute, null ) } addBookmarkRequest = false } }, onSnippetForBookmarkReady = { cfi, snippet -> Timber.d("Vertical add: onSnippetForBookmarkReady called. CFI: '$cfi', Snippet: '$snippet'" ) val chapterTitle = epubBook.chapters.getOrNull(currentChapterIndex)?.title ?: context.getString(R.string.unknown_chapter) val newBookmark = Bookmark( cfi = cfi, chapterTitle = chapterTitle, label = null, snippet = snippet, pageInChapter = currentPageInChapter, totalPagesInChapter = totalPagesInCurrentChapter, chapterIndex = currentChapterIndex ) bookmarks = bookmarks + newBookmark Timber.d("Vertical add: Created bookmark: $newBookmark" ) }, onTopChunkUpdated = { chunkIndex -> if (isCurrentRenderedChapter()) { topVisibleChunkIndex = chunkIndex } else { Timber.tag(TAG_VERTICAL_JITTER).d( "ignored stale topChunk rendered=$targetChapterIndex current=$currentChapterIndex chunk=$chunkIndex chapter='${chapterToRender.title}'" ) } }, initialHtmlContent = initialHtml, baseUrl = baseUrl, totalChunks = chapterChunks.size, initialChunkIndex = loadUpToChunkIndex, onChunkRequested = { index -> if (!isCurrentRenderedChapter()) { Timber.tag(TAG_VERTICAL_JITTER).d( "ignored stale chunkRequest rendered=$targetChapterIndex current=$currentChapterIndex chunk=$index chapter='${chapterToRender.title}'" ) } else { val chunkContent = chapterChunks.getOrNull(index) if (chunkContent != null) { loadedChunkCount = max(loadedChunkCount, index + 1) val escapedContent = escapeJsString(chunkContent) val jsCommand = "javascript:window.virtualization.appendChunk($index, '$escapedContent');" webViewRefForTts?.evaluateJavascript( jsCommand, null ) } } }, ) } } if (pullToTurnEnabled && currentChapterIndex > 0) { ChapterChangeIndicator( text = stringResource(R.string.release_for_previous_chapter), progress = pullToPrevProgress, isPullingDown = true, modifier = Modifier .align(Alignment.TopCenter) .padding(top = 8.dp) ) } if (pullToTurnEnabled && currentChapterIndex < chapters.size - 1) { ChapterChangeIndicator( text = stringResource(R.string.release_for_next_chapter), progress = pullToNextProgress, isPullingDown = false, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 8.dp) ) } } } } RenderMode.PAGINATED -> { val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(top = contentTopPadding) .padding(bottom = contentBottomPadding) .testTag("ReaderContainer") ) { PaginatedReaderScreen( book = epubBook, bookId = readerCacheBookId, isDarkTheme = isDarkTheme, effectiveBg = effectiveBg, effectiveText = effectiveText, pagerState = paginatedPagerState, isRightToLeftPagination = rightToLeftPagination, searchQuery = searchState.searchQuery, fontSizeMultiplier = currentFontSizeEm, lineHeightMultiplier = currentLineHeight, paragraphGapMultiplier = currentParagraphGap, imageSizeMultiplier = currentImageSize, horizontalMarginMultiplier = currentHorizontalMargin, verticalMarginMultiplier = currentVerticalMargin, fontFamily = activeFontFamily, textAlign = currentTextAlign, activeHighlightPalette = currentHighlightPalette, onUpdatePalette = onUpdateHighlightPalette, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, ttsHighlightInfo = TtsHighlightInfo( text = ttsState.currentText ?: "", cfi = ttsState.sourceCfi ?: "", offset = ttsState.startOffsetInSource ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, activeTextureId = activeTextureId, activeTextureAlpha = activeTextureAlpha, initialChapterIndexInBook = lastKnownLocator?.chapterIndex, fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator, explicitNavigationAnchor = paginatedExplicitNavigationAnchor, explicitNavigationEpoch = paginatedExplicitNavigationEpoch, isExternalNavigationInProgress = isNavigatingToPosition || isNavigatingByToc, onReconfigurationAnchorCaptured = { locator -> paginatedReconfigurationAnchor = locator lastKnownLocator = locator }, onReconfigurationRestoreActiveChanged = { isActive -> isPaginatedReconfigurationRestoring = isActive if (!isActive) { paginatedReconfigurationAnchor = null } }, modifier = Modifier.alpha(if (isPagerInitialized && !isPaginatedReconfigurationRestoring) 1f else 0f), onPaginatorReady = { newPaginator -> paginator = newPaginator }, onTap = { tapOffset -> Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset") if (volumeScrollEnabled) { containerFocusRequester.requestFocus() } if (tapOffset == null || !tapToNavigateEnabled) { focusManager.clearFocus() if (volumeScrollEnabled) containerFocusRequester.requestFocus() if (showBars || showFormatAdjustmentBars) { showBars = false showFormatAdjustmentBars = false } else { showBars = true } } else { val oneQuarterWidthPx = constraints.maxWidth / 4f when { tapOffset.x < oneQuarterWidthPx -> { scope.launch { val targetPage = if (rightToLeftPagination) { (paginatedPagerState.currentPage + 1).coerceAtMost(paginatedPagerState.pageCount - 1) } else { (paginatedPagerState.currentPage - 1).coerceAtLeast(0) } if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) } else paginatedPagerState.scrollToPage(targetPage) } } } tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> { scope.launch { val pageCount = paginatedPagerState.pageCount if (pageCount > 0) { val targetPage = if (rightToLeftPagination) { (paginatedPagerState.currentPage - 1).coerceAtLeast(0) } else { (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1) } if (targetPage != paginatedPagerState.currentPage) { if (isPageTurnAnimationEnabled) { paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700)) } else paginatedPagerState.scrollToPage(targetPage) } } } } else -> { focusManager.clearFocus() if (volumeScrollEnabled) containerFocusRequester.requestFocus() if (showBars || showFormatAdjustmentBars) { showBars = false showFormatAdjustmentBars = false } else { showBars = true } } } } }, isProUser = isProUser, isOss = BuildConfig.FLAVOR == "oss", onShowDictionaryUpsellDialog = { showDictionaryUpsellDialog = true }, onWordSelectedForAiDefinition = { text -> onDictionaryLookup(text) }, onTranslate = { text -> onTranslateLookup(text) }, onSearch = { text -> onSearchLookup(text) }, onStartTtsFromSelection = { cfi, offset -> startTtsFromSelectionPaginated(cfi, offset) }, userHighlights = userHighlights.filter { highlight -> val currentChapter = currentChapterInPaginatedMode ?: return@filter false highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1) }, onHighlightCreated = { cfi, text, colorId -> val chapterIndex = currentChapterInPaginatedMode ?: 0 Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " + "existingCount=${userHighlights.size} textLen=${text.length} " + "text='${epubHighlightDiagSnippet(text)}'" ) Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW val finalCfi = processAndAddHighlight( newCfi = cfi, newText = text, newColor = color, chapterIndex = chapterIndex, currentList = userHighlights ) val savedHighlight = userHighlights.find { it.chapterIndex == chapterIndex && it.cfi == finalCfi } Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "persist_result finalCfi=$finalCfi chapter=$chapterIndex " + "savedId=${savedHighlight?.id} totalCount=${userHighlights.size} " + "matchingCfiCount=${userHighlights.count { it.chapterIndex == chapterIndex && it.cfi == finalCfi }} " + "locatorStart=${savedHighlight?.locator?.startOffset} " + "locatorEnd=${savedHighlight?.locator?.endOffset} " + "locatorPage=${savedHighlight?.locator?.pageIndex} " + "locatorCfi=${savedHighlight?.locator?.cfi}" ) if (pendingNoteForNewHighlight) { pendingNoteForNewHighlight = false highlightToNoteCfi = finalCfi } }, onNoteRequested = { cfi -> if (cfi != null) { highlightToNoteCfi = cfi } else { pendingNoteForNewHighlight = true } }, onFootnoteRequested = { html -> activeFootnoteHtml = html }, onInternalLinkNavigated = { targetPageIndex -> val bookPaginator = paginator as? BookPaginator val targetChapter = bookPaginator?.findChapterIndexForPage(targetPageIndex) val targetLocator = bookPaginator?.getLocatorForPage(targetPageIndex) val navigationEpoch = System.currentTimeMillis() paginatedExplicitNavigationEpoch = navigationEpoch paginatedExplicitNavigationAnchor = targetLocator Timber.tag(TAG_STABLE_PAGE_NAV).d( "internal_link_target targetPage=$targetPageIndex targetChapter=$targetChapter anchor=$targetLocator epoch=$navigationEpoch" ) if (targetLocator != null) { lastKnownLocator = targetLocator } bookPaginator?.onUserScrolledTo(targetPageIndex) paginatedJumpLocatorForPage( pageIndex = targetPageIndex, targetLocator = targetLocator, fallbackChapterIndex = targetChapter )?.let { recordEpubJump(it) } }, onHighlightDeleted = { cfi -> val beforeCount = userHighlights.size val toRemove = userHighlights.find { it.cfi == cfi } if (toRemove != null) { Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "delete_request cfi=$cfi matchedId=${toRemove.id} " + "matchedChapter=${toRemove.chapterIndex} beforeCount=$beforeCount " + "locatorStart=${toRemove.locator.startOffset} " + "locatorEnd=${toRemove.locator.endOffset}" ) userHighlights.remove(toRemove) Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d( "delete_result cfi=$cfi removedId=${toRemove.id} " + "afterCount=${userHighlights.size}" ) } else { Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w( "delete_request cfi=$cfi matchedId=null beforeCount=$beforeCount" ) } } ) if (!isPagerInitialized) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } } } } val isBookmarked: Boolean val onBookmarkClick: () -> Unit when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { val checkVisibleBookmarks = remember(webViewRefForTts, bookmarks, currentChapterIndex) { { val currentChapter = chapters.getOrNull(currentChapterIndex) if (currentChapter == null) { activeBookmarkInVerticalView = null return@remember } val bookmarksForCurrentChapter = bookmarks.filter { it.chapterTitle == currentChapter.title } if (bookmarksForCurrentChapter.isEmpty()) { if (activeBookmarkInVerticalView != null) { Timber.d("No bookmarks for this chapter, clearing active bookmark.") activeBookmarkInVerticalView = null } return@remember } val cfiJsonArray = "['" + bookmarksForCurrentChapter.joinToString("','") { escapeJsString(it.cfi) } + "']" webViewRefForTts?.evaluateJavascript("javascript:window.findFirstVisibleCfi($cfiJsonArray)") { result -> val visibleCfi = result?.takeIf { it != "null" && it != "\"\"" }?.removeSurrounding("\"") val visibleBookmark = visibleCfi?.let { cfi -> bookmarks.find { it.cfi == cfi } } if (activeBookmarkInVerticalView != visibleBookmark) { activeBookmarkInVerticalView = visibleBookmark } } } } LaunchedEffect(isChapterReadyForBookmarkCheck, bookmarks, currentChapterIndex) { if (isChapterReadyForBookmarkCheck && renderMode == RenderMode.VERTICAL_SCROLL) { checkVisibleBookmarks() } } LaunchedEffect(currentScrollYPosition) { if (isChapterReadyForBookmarkCheck && renderMode == RenderMode.VERTICAL_SCROLL) { val now = System.currentTimeMillis() if (now - lastBookmarkCheckTime > 300L) { lastBookmarkCheckTime = now Timber.d("Bookmark check on scroll throttle.") checkVisibleBookmarks() } delay(400L) Timber.d("Bookmark check on scroll stopped (debounced).") checkVisibleBookmarks() } } isBookmarked = activeBookmarkInVerticalView != null onBookmarkClick = { if (isBookmarked) { activeBookmarkInVerticalView?.let { bookmarkToRemove -> Timber.d("Vertical click: Removing bookmark: $bookmarkToRemove") bookmarks = bookmarks - bookmarkToRemove } } else { Timber.d("Vertical click: Adding bookmark. Requesting CFI.") addBookmarkRequest = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiForBookmarkExtracted(window.getCurrentCfi());", null) } } } RenderMode.PAGINATED -> { val pageContent = remember(paginatedPagerState.currentPage, paginator) { paginator?.getPageContent(paginatedPagerState.currentPage) } val blocksOnPage = remember(pageContent) { pageContent?.content ?: emptyList() } val bookPaginator = paginator as? BookPaginator val bookmarkedOnPage = remember(paginatedPagerState.currentPage, bookmarkPageMap, bookmarks) { bookmarks.find { bookmark -> bookmarkPageMap[bookmark.cfi] == paginatedPagerState.currentPage } } isBookmarked = bookmarkedOnPage != null onBookmarkClick = { if (isBookmarked) { bookmarkedOnPage.let { bookmarkToRemove -> bookmarks = bookmarks - bookmarkToRemove Timber.d("Paginated click: Removing bookmark: $bookmarkToRemove") } } else { val firstTextBlockOnPage = blocksOnPage.firstOrNull { it is TextContentBlock && it.cfi != null } val targetBlockForBookmark = firstTextBlockOnPage ?: blocksOnPage.firstOrNull { it.cfi != null } if (targetBlockForBookmark != null) { val baseCfi = targetBlockForBookmark.cfi!! val offset = when (targetBlockForBookmark) { is ParagraphBlock -> targetBlockForBookmark.startCharOffsetInSource is HeaderBlock -> targetBlockForBookmark.startCharOffsetInSource is QuoteBlock -> targetBlockForBookmark.startCharOffsetInSource is ListItemBlock -> targetBlockForBookmark.startCharOffsetInSource else -> 0 } val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage) val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: context.getString(R.string.unknown_chapter) val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: "" val pageInChapter: Int? val totalPages: Int? if (bookPaginator != null && chapterIndex != null) { val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] totalPages = bookPaginator.chapterPageCounts[chapterIndex] pageInChapter = if (chapterStartPage != null) { paginatedPagerState.currentPage - chapterStartPage + 1 } else { null } } else { pageInChapter = null totalPages = null } if (chapterIndex != null) { val newBookmark = Bookmark( cfi = finalCfi, chapterTitle = chapterTitle, label = null, snippet = snippet, pageInChapter = pageInChapter, totalPagesInChapter = totalPages, chapterIndex = chapterIndex ) bookmarks = bookmarks + newBookmark Timber.d("Paginated click: Adding bookmark: $newBookmark") } } } } } } BookmarkButton( isBookmarked = isBookmarked, onClick = onBookmarkClick, modifier = Modifier .align(Alignment.TopEnd) .padding(end = 16.dp) ) // Page Info Bar (Vertical) AnimatedVisibility( visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), modifier = Modifier.align( if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter ) ) { Box( modifier = Modifier .fillMaxWidth() .height(pageInfoBarHeight) .background(infoBarBgColor) .then(activeTextureModifier) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center ) { val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim() ?: "Chapter" val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)" Text( text = "$chapterTitle$displayPageInfo", style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .fillMaxWidth() .padding(horizontal = 48.dp) ) if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) { Text( text = "%.1f%%".format(currentBookProgress), style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) } } } // Page Info Bar (Paginated) AnimatedVisibility( visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0, enter = fadeIn(animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)), modifier = Modifier.align( if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter ) ) { Box( modifier = Modifier .fillMaxWidth() .height(pageInfoBarHeight) .background(infoBarBgColor) .then(activeTextureModifier) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center ) { val bookPaginator = paginator as? BookPaginator val chapterIndex = currentChapterInPaginatedMode val textToShow = if (bookPaginator != null && chapterIndex != null) { val chapterTitle = chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim() ?: stringResource(R.string.chapter) val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex] val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] if (totalPagesInChapter != null && chapterStartPage != null && totalPagesInChapter > 0) { val currentPageInChapter = paginatedPagerState.currentPage - chapterStartPage + 1 "$chapterTitle ($currentPageInChapter/$totalPagesInChapter)" } else { chapterTitle } } else { stringResource(R.string.page_number_of_total, paginatedPagerState.currentPage + 1, paginatedPagerState.pageCount) } Text( text = textToShow, style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .fillMaxWidth() .padding(horizontal = 48.dp) ) // Right-aligned Percentage if (paginatedPagerState.pageCount > 0) { if (totalBookLengthChars > 0 && bookPaginator != null && chapterIndex != null) { val completedCharsInPreviousChapters = remember(chapters, chapterIndex) { chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } } val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] val currentPageInChapter = if (chapterStartPage != null) { paginatedPagerState.currentPage - chapterStartPage } else { 0 } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) val totalCharsScrolled = completedCharsInPreviousChapters + charsScrolledInCurrentChapter val calculatedProgress = (totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0 val isLastPageOfBook = paginatedPagerState.currentPage == paginatedPagerState.pageCount - 1 val displayProgress = if (isLastPageOfBook) { 100.0 } else { floor(calculatedProgress.coerceAtMost(100.0) * 10) / 10.0 } Text( text = "%.1f%%".format(displayProgress), style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) } else { val totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { totalPagesInCurrentChapter } else { paginatedPagerState.pageCount } val currentPageOneIndexed = paginatedPagerState.currentPage + 1 val percentage = (currentPageOneIndexed.toFloat() / totalPages.toFloat()) * 100f Text( text = "%.1f%%".format(percentage), style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.End, modifier = Modifier.align(Alignment.CenterEnd) ) } } } } if (isMusicianMode && isAutoScrollModeActive) { val density = LocalDensity.current 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" ) val rightPulseAlpha by animateFloatAsState( targetValue = if (System.currentTimeMillis() - rightPulseTrigger < 150) 0.3f else 0f, animationSpec = tween(150), label = "rightPulse" ) Box(modifier = Modifier.fillMaxSize()) { val regionHeight = Modifier.fillMaxHeight(0.4f) val regionWidth = Modifier.fillMaxWidth(0.25f) val topOffset = 100.dp // Left Region Box( modifier = regionWidth .then(regionHeight) .align(Alignment.TopStart) .offset(y = topOffset) .padding(start = 8.dp) .background(MaterialTheme.colorScheme.primary.copy(alpha = leftPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) .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) scope.launch { webViewRefForTts?.evaluateJavascript( "window.scrollTo({ top: 0, behavior: 'auto' });", null ) } break } leftHoldProgress = elapsed / 1000f delay(16) } } val up = waitForUpOrCancellation() job.cancel() leftHoldProgress = 0f if (!isLongPress && up != null) { up.consume() leftPulseTrigger = System.currentTimeMillis() triggerAutoScrollTempPause(600L) val amount = (currentClientHeightValue * 0.75f).toInt() webViewRefForTts?.evaluateJavascript( "window.scrollBy({ top: -${amount}, behavior: 'smooth' });", null ) } } }, contentAlignment = Alignment.Center ) { if (leftHoldProgress > 0f) { CircularProgressIndicator( progress = { leftHoldProgress }, modifier = Modifier.size(48.dp).alpha(0.6f), color = MaterialTheme.colorScheme.onSurface, trackColor = Color.Transparent, strokeWidth = 4.dp ) Icon( imageVector = Icons.Default.ArrowUpward, contentDescription = null, modifier = Modifier.size(24.dp).alpha(0.6f), tint = MaterialTheme.colorScheme.onSurface ) } } // Right Region Box( modifier = regionWidth .then(regionHeight) .align(Alignment.TopEnd) .offset(y = topOffset) .padding(end = 8.dp) .background(MaterialTheme.colorScheme.primary.copy(alpha = rightPulseAlpha), RoundedCornerShape(12.dp)) .border(2.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) .pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() var isLongPress = false val job = scope.launch { val startTime = System.currentTimeMillis() while (isActive) { val elapsed = System.currentTimeMillis() - startTime if (elapsed >= 1000) { rightHoldProgress = 0f isLongPress = true rightPulseTrigger = System.currentTimeMillis() triggerAutoScrollTempPause(1000L) scope.launch { webViewRefForTts?.evaluateJavascript( "window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' });", null ) } break } rightHoldProgress = elapsed / 1000f delay(16) } } val up = waitForUpOrCancellation() job.cancel() rightHoldProgress = 0f if (!isLongPress && up != null) { up.consume() rightPulseTrigger = System.currentTimeMillis() triggerAutoScrollTempPause(600L) val amount = (currentClientHeightValue * 0.75f).toInt() webViewRefForTts?.evaluateJavascript( "window.scrollBy({ top: ${amount}, behavior: 'smooth' });", null ) } } }, contentAlignment = Alignment.Center ) { if (rightHoldProgress > 0f) { CircularProgressIndicator( progress = { rightHoldProgress }, modifier = Modifier.size(48.dp).alpha(0.6f), color = MaterialTheme.colorScheme.onSurface, trackColor = Color.Transparent, strokeWidth = 4.dp ) Icon( imageVector = Icons.Default.ArrowDownward, contentDescription = null, modifier = Modifier.size(24.dp).alpha(0.6f), tint = MaterialTheme.colorScheme.onSurface ) } } } } Box(modifier = Modifier.fillMaxSize()) { EpubReaderSearchOverlay( searchState = searchState, onNavigateResult = { index -> navigateToSearchResult(index) }, bottomPadding = bottomPadding ) } val navBarScrimColor = MaterialTheme.colorScheme.surface Box( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .windowInsetsBottomHeight(WindowInsets.navigationBars) .background(navBarScrimColor) ) Box( modifier = Modifier .align(Alignment.CenterStart) .fillMaxHeight() .windowInsetsStartWidth(WindowInsets.navigationBars) .background(navBarScrimColor) ) Box( modifier = Modifier .align(Alignment.CenterEnd) .fillMaxHeight() .windowInsetsEndWidth(WindowInsets.navigationBars) .background(navBarScrimColor) ) // Animated Top Bar EpubReaderTopBar( isVisible = showBars, searchState = searchState, bookTitle = epubBook.title, currentRenderMode = currentRenderMode, isBookmarked = isBookmarked, isTtsActive = isTtsSessionActive, isSliderActive = isPageSliderVisible, tapToNavigateEnabled = tapToNavigateEnabled, volumeScrollEnabled = volumeScrollEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isRightToLeftPagination = rightToLeftPagination, hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, onCustomizeTools = { showCustomizeToolsSheet = true }, onNavigateBack = { triggerSaveAndExit() }, isKeepScreenOn = isKeepScreenOn, onToggleKeepScreenOn = { enabled -> isKeepScreenOn = enabled saveKeepScreenOn(context, enabled) }, onCloseSearch = { searchState.isSearchActive = false searchState.onQueryChange("") keyboardController?.hide() focusManager.clearFocus() containerFocusRequester.requestFocus() webViewRefForTts?.evaluateJavascript("javascript:window.clearSearchHighlights();", null) }, onChangeRenderMode = { newMode -> Timber.tag("NavDiag").d("onChangeRenderMode to $newMode") if (newMode != currentRenderMode) { if (newMode == RenderMode.PAGINATED) { isSwitchingToPaginated = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) } else { scope.launch { Timber.tag("NavDiag").d("Mode changing to VERTICAL. lastKnownLocator=$lastKnownLocator") lastKnownLocator?.let { locator -> val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) Timber.tag("NavDiag").d("Converted locator to CFI: $cfi") if (cfi != null) { val targetChunk = locator.blockIndex / 20 chunkTargetOverride = targetChunk if (currentChapterIndex != locator.chapterIndex) { initialScrollTargetForChapter = null currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = locator.chapterIndex } else { if (targetChunk > loadUpToChunkIndex) { loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } initialScrollTargetForChapter = null } cfiToLoad = cfi } else { currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = locator.chapterIndex cfiToLoad = null } currentRenderMode = RenderMode.VERTICAL_SCROLL onRenderModeChange(RenderMode.VERTICAL_SCROLL) } } } } }, onToggleBookmark = onBookmarkClick, onToggleTapToNavigate = { enabled -> tapToNavigateEnabled = enabled saveTapToNavigateSetting(context, enabled) }, onTogglePageTurnAnimation = { enabled -> isPageTurnAnimationEnabled = enabled savePageTurnAnimationSetting(context, enabled) }, onSetRightToLeftPagination = { enabled -> rightToLeftPagination = enabled saveEpubRightToLeftPagination(context, enabled) }, onToggleVolumeScroll = { enabled -> volumeScrollEnabled = enabled saveVolumeScrollSetting(context, enabled) }, onStartAutoScroll = { isAutoScrollModeActive = true isAutoScrollPlaying = true showBars = !isMusicianMode }, searchFocusRequester = searchFocusRequester, modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsReplacements = { showTtsReplacementsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenBrightness = { showBrightnessSheet = true }, onOpenVisualOptions = { showVisualOptionsSheet = true }, onOpenScreenOrientation = { showScreenOrientationSheet = true }, onOpenAiHub = { showAiHubSheet = true }, onOpenSlider = ::toggleEpubPageSlider, onOpenDrawer = { scope.launch { drawerState.open() } }, onToggleFormat = { showFormatAdjustmentBars = !showFormatAdjustmentBars if (showFormatAdjustmentBars) { searchState.showSearchResultsPanel = false resetEpubSliderBookmark() isPageSliderVisible = false } }, onToggleSearch = { searchState.isSearchActive = true searchState.showSearchResultsPanel = true showBars = true showFormatAdjustmentBars = false }, onToggleTts = { if (isTtsSessionActive) { Timber.d("TTS button clicked: Stopping TTS") userStoppedTts = true ttsController.stop() } else { when { ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED -> { startTts() } activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> { showPermissionRationaleDialog = true } else -> { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } }, onOpenFileInfo = { showFileInfoDialog = true }, onToggleReflow = if (onToggleReflow != null) { { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { currentChapterInPaginatedMode ?: currentChapterIndex } else { currentChapterIndex } onToggleReflow(activeChapter) } } else null, onDeleteReflow = onDeleteReflow ) val autoScrollPadding by animateDpAsState( targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp, label = "AutoScrollPadding" ) val alignmentBias by animateFloatAsState( targetValue = if (isAutoScrollCollapsed) 1f else 0f, label = "AutoScrollAlignAnimation" ) val ttsOverlayPadding by animateDpAsState( targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp, label = "TtsOverlayPadding" ) val ttsAlignmentBias by animateFloatAsState( targetValue = if (isTtsCollapsed) 1f else 0f, label = "TtsAlignAnimation" ) AnimatedVisibility( visible = isTtsSessionActive && showBars, enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), modifier = Modifier .align(BiasAlignment(ttsAlignmentBias, 1f)) .padding(bottom = ttsOverlayPadding) .padding(horizontal = 16.dp) ) { TtsOverlayControls( ttsController = ttsController, ttsState = ttsState, currentTtsMode = currentTtsMode, isCollapsed = isTtsCollapsed, onCollapseChange = { isTtsCollapsed = it }, onLocateCurrentChunk = { logTtsChapterDiag("Locate current chunk requested from TTS overlay") queuePendingTtsLocate(TTS_LOCATE_REASON_OVERLAY) }, onOpenTtsSettings = { showTtsSettingsSheet = true }, onClose = { userStoppedTts = true ttsController.stop() }, credits = credits ) } val isAutoScrollControlsVisible = isAutoScrollModeActive AnimatedVisibility( visible = isAutoScrollControlsVisible, enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), modifier = Modifier .align(BiasAlignment(alignmentBias, 1f)) .padding(bottom = autoScrollPadding) .padding(horizontal = 16.dp) ) { AutoScrollControls( isPlaying = isAutoScrollPlaying, isTempPaused = isAutoScrollTempPaused, onPlayPauseToggle = { if (isAutoScrollPlaying) { isAutoScrollPlaying = false isAutoScrollTempPaused = false autoScrollResumeJob.value?.cancel() } else { isAutoScrollPlaying = true isAutoScrollTempPaused = false } }, speed = autoScrollSpeed, minSpeed = autoScrollMinSpeed, maxSpeed = autoScrollMaxSpeed, onSpeedChange = { updateSpeed(it) }, onMinSpeedChange = { newMin -> updateMinSpeed(newMin) if (!isAutoScrollLocal) { if (autoScrollMaxSpeed < newMin) { autoScrollMaxSpeed = newMin saveAutoScrollMaxSpeed(context, newMin) } if (autoScrollSpeed < newMin) { autoScrollSpeed = newMin saveAutoScrollSpeed(context, newMin) } else if (autoScrollSpeed > autoScrollMaxSpeed) { autoScrollSpeed = autoScrollMaxSpeed saveAutoScrollSpeed(context, autoScrollMaxSpeed) } } }, onMaxSpeedChange = { newMax -> updateMaxSpeed(newMax) if (!isAutoScrollLocal) { if (autoScrollMinSpeed > newMax) { autoScrollMinSpeed = newMax saveAutoScrollMinSpeed(context, newMax) } if (autoScrollSpeed > newMax) { autoScrollSpeed = newMax saveAutoScrollSpeed(context, newMax) } else if (autoScrollSpeed < autoScrollMinSpeed) { autoScrollSpeed = autoScrollMinSpeed saveAutoScrollSpeed(context, autoScrollMinSpeed) } } }, onClose = { isAutoScrollModeActive = false isAutoScrollPlaying = false showBars = true }, isCollapsed = isAutoScrollCollapsed, onCollapseChange = { isAutoScrollCollapsed = it }, isMusicianMode = isMusicianMode, onMusicianModeToggle = { val newMode = !isMusicianMode isMusicianMode = newMode saveMusicianMode(context, newMode) if (newMode) { showBars = false showFormatAdjustmentBars = false } Timber.d("Musician mode toggled: $newMode") }, useSlider = autoScrollUseSlider, onInputModeToggle = { autoScrollUseSlider = !autoScrollUseSlider saveAutoScrollUseSlider(context, autoScrollUseSlider) }, isLocalMode = isAutoScrollLocal, onLocalModeToggle = onToggleAutoScrollMode, onScrollToTop = { if (isAutoScrollPlaying) { triggerAutoScrollTempPause(1000L) } scope.launch { webViewRefForTts?.evaluateJavascript("window.scrollTo({ top: 0, behavior: 'smooth' });", null) } } ) } EpubJumpHistoryBar( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = bottomPadding + 45.dp), showStandardBars = showBars, searchStateActive = searchState.isSearchActive, backLabel = epubJumpBackLabel, forwardLabel = epubJumpForwardLabel, onBack = ::goBackInEpubJumpHistory, onForward = ::goForwardInEpubJumpHistory, onClear = { epubJumpHistory = epubJumpHistory.clear() } ) // Animated Bottom Bar EpubReaderBottomBar( isVisible = showBars, currentRenderMode = currentRenderMode, isTtsSessionActive = isTtsSessionActive, ttsState = ttsState, isProUser = isProUser, hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, currentTtsMode = currentTtsMode, isSliderActive = isPageSliderVisible, onOpenAiHub = { showAiHubSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenBrightness = { showBrightnessSheet = true }, onOpenSlider = ::toggleEpubPageSlider, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenScreenOrientation = { showScreenOrientationSheet = true }, onToggleFormat = { showFormatAdjustmentBars = !showFormatAdjustmentBars if (showFormatAdjustmentBars) { searchState.showSearchResultsPanel = false resetEpubSliderBookmark() isPageSliderVisible = false } }, onToggleSearch = { searchState.isSearchActive = true searchState.showSearchResultsPanel = true showBars = true showFormatAdjustmentBars = false }, onToggleTts = { if (isTtsSessionActive) { Timber.d("TTS button clicked: Stopping TTS") userStoppedTts = true ttsController.stop() } else { when { ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED -> { startTts() } activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> { showPermissionRationaleDialog = true } else -> { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } }, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = bottomPadding) ) ReaderTextFormatPanel( isVisible = showFormatAdjustmentBars, currentFontSize = currentFontSizeEm, onFontSizeChange = { currentFontSizeEm = it }, currentLineHeight = currentLineHeight, onLineHeightChange = { currentLineHeight = it }, currentParagraphGap = currentParagraphGap, onParagraphGapChange = { currentParagraphGap = it }, currentImageSize = currentImageSize, onImageSizeChange = { currentImageSize = it }, currentHorizontalMargin = currentHorizontalMargin, onHorizontalMarginChange = { currentHorizontalMargin = it }, currentVerticalMargin = currentVerticalMargin, onVerticalMarginChange = { currentVerticalMargin = it }, currentFont = currentFontFamily, currentCustomFontName = if(currentCustomFontPath != null) { customFonts.find { it.path == currentCustomFontPath }?.displayName ?: stringResource(R.string.custom_font_fallback) } else null, onFontOptionClick = { showFontSelectionSheet = true }, currentTextAlign = currentTextAlign, onTextAlignChange = { newAlign -> currentTextAlign = newAlign if (newAlign == ReaderTextAlign.JUSTIFY && currentRenderMode == RenderMode.PAGINATED) { showJustifyWarningDialog = true } }, onReset = { currentFontSizeEm = DEFAULT_FONT_SIZE_VAL currentLineHeight = DEFAULT_LINE_HEIGHT_VAL currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL currentImageSize = DEFAULT_IMAGE_SIZE_VAL currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL currentFontFamily = ReaderFont.ORIGINAL currentCustomFontPath = null currentTextAlign = ReaderTextAlign.DEFAULT }, isLocalMode = isFormatLocal, onLocalModeToggle = { isFormatLocal = it saveFormatIsLocal(context, bookId, it) }, onClose = { showFormatAdjustmentBars = false } ) val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) { currentChapterInPaginatedMode ?: currentChapterIndex } else { currentChapterIndex } EpubReaderAiOverlays( bookTitle = epubBook.title, summaryCacheManager = summaryCacheManager, summarizationResult = summarizationResult, isSummarizationLoading = isSummarizationLoading, showSummarizationUpsellDialog = showSummarizationUpsellDialog, onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false }, recapResult = recapResult, isRecapLoading = isRecapLoading, showAiDefinitionPopup = showAiDefinitionPopup, selectedTextForAi = selectedTextForAi, aiDefinitionResult = aiDefinitionResult, isAiDefinitionLoading = isAiDefinitionLoading, onDismissAiDefinition = { showAiDefinitionPopup = false selectedTextForAi = null aiDefinitionResult = null webViewRefForTts?.evaluateJavascript( "javascript:if(window.getSelection){window.getSelection().removeAllRanges();} else if(document.selection){document.selection.empty();}", null ) }, showDictionaryUpsellDialog = showDictionaryUpsellDialog, onDismissDictionaryUpsell = { showDictionaryUpsellDialog = false }, onNavigateToPro = onNavigateToPro, isTtsSessionActive = isTtsSessionActive, onOpenExternalDictionary = { text -> if (!selectedDictPackage.isNullOrEmpty()) { ExternalDictionaryHelper.launchDictionary( context, selectedDictPackage!!, text ) } else { Toast.makeText( context, "Select an offline dictionary first.", Toast.LENGTH_SHORT ).show() showDictionarySettingsSheet = true } }, getAuthToken = { viewModel.getAuthToken() }, credits = credits, isProUser = isProUser, currentChapterIndex = effectiveCurrentChapterIndex, chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: context.getString(R.string.chapter_number_format, effectiveCurrentChapterIndex + 1), showAiHubSheet = showAiHubSheet, onGenerateSummary = handleGenerateSummary, onGenerateRecap = handleGenerateRecap, onDismissAiHub = { showAiHubSheet = false }, onClearSummary = { summarizationResult = null }, onClearRecap = { recapResult = null } ) if (isNavigatingToPosition && currentRenderMode == RenderMode.PAGINATED) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) .clickable(enabled = true) {}, contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator() Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.navigating_to_position), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground ) } } } if (showPermissionRationaleDialog) { AlertDialog( onDismissRequest = { showPermissionRationaleDialog = false }, title = { Text(stringResource(R.string.dialog_permission_required)) }, text = { Text(stringResource(R.string.dialog_permission_notification_desc)) }, confirmButton = { TextButton( onClick = { showPermissionRationaleDialog = false permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } ) { Text(stringResource(R.string.action_continue)) } }, dismissButton = { TextButton( onClick = { showPermissionRationaleDialog = false startTts() } ) { Text(stringResource(R.string.action_not_now)) } } ) } if (showJustifyWarningDialog) { AlertDialog( onDismissRequest = { showJustifyWarningDialog = false }, icon = { Icon(Icons.Default.Info, contentDescription = null) }, title = { Text(stringResource(R.string.dialog_justified_text_limitation)) }, text = { Text(stringResource(R.string.dialog_justified_text_limitation_desc)) }, confirmButton = { TextButton(onClick = { showJustifyWarningDialog = false }) { Text(stringResource(R.string.action_i_understand)) } } ) } AnimatedVisibility( visible = isNavigatingByToc, enter = fadeIn(), exit = fadeOut() ) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)) .clickable(enabled = true) { }, contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator() Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.navigating_to_chapter), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground ) } } } if (highlightToNoteCfi != null) { val targetHighlight = userHighlights.find { it.cfi == highlightToNoteCfi || (highlightToNoteCfi != null && it.cfi.contains(highlightToNoteCfi!!)) } if (targetHighlight != null) { AnnotationBottomSheet( highlight = targetHighlight, effectiveBg = effectiveBg, effectiveText = effectiveText, activeHighlightPalette = currentHighlightPalette, onColorChange = { newColor -> onHighlightColorChange(targetHighlight, newColor) }, onOpenPaletteManager = { showPaletteManager = true }, onDismiss = { highlightToNoteCfi = null }, onSave = { noteText -> val index = userHighlights.indexOfFirst { it.cfi == targetHighlight.cfi } if (index != -1) { userHighlights[index] = targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() }) } highlightToNoteCfi = null }, onDelete = { userHighlights.remove(targetHighlight) if (currentRenderMode == RenderMode.VERTICAL_SCROLL && targetHighlight.chapterIndex == currentChapterIndex) { val cssClass = targetHighlight.color.cssClass val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString( targetHighlight.cfi)}', '$cssClass');" webViewRefForTts?.evaluateJavascript(jsCommand, null) } highlightToNoteCfi = null }, onCopy = { val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text) clipboardManager.setPrimaryClip(clip) highlightToNoteCfi = null }, onDictionary = { onDictionaryLookup(targetHighlight.text) highlightToNoteCfi = null }, onTranslate = { onTranslateLookup(targetHighlight.text) highlightToNoteCfi = null }, onSearch = { onSearchLookup(targetHighlight.text) highlightToNoteCfi = null } ) } } if (activeFootnoteHtml != null) { FootnoteBottomSheet( htmlContent = activeFootnoteHtml!!, effectiveBg = effectiveBg, effectiveText = effectiveText, onDismiss = { activeFootnoteHtml = null } ) } EpubReaderPageSlider( isVisible = epubSliderChromeVisible, currentRenderMode = currentRenderMode, totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, sliderCurrentPage = sliderCurrentPage, sliderStartPage = sliderStartPage, startPageThumbnail = startPageThumbnail, paginator = paginator, chapters = chapters, onScrub = { newValue -> sliderCurrentPage = newValue isFastScrubbing = true scrubDebounceJob.value?.cancel() scrubDebounceJob.value = scope.launch { delay(200) if (isActive) { val targetPage = newValue.roundToInt() if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { val scrollY = (targetPage - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) } else { paginatedPagerState.scrollToPage(targetPage - 1) } isFastScrubbing = false } } }, onJumpToPage = { page -> scope.launch { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { sliderCurrentPage = page.toFloat() val scrollY = (page - 1) * currentClientHeightValue webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) } else { sliderCurrentPage = page.toFloat() val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1) scrollPaginatedToJumpPage(page - 1, targetLocator) } } }, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp), activeColor = epubReaderSliderColors.activeTrackColor, inactiveColor = epubReaderSliderColors.inactiveTrackColor, contentColor = epubReaderSliderColors.contentColor, thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor, thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor ) if (epubSliderChromeVisible && isFastScrubbing) { val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } } } if (showTtsSettingsSheet) { TtsSettingsSheet( isVisible = true, onDismiss = { showTtsSettingsSheet = false }, currentMode = currentTtsMode, onModeChange = { newMode -> currentTtsMode = newMode saveTtsMode(context, newMode.name) ttsController.changeTtsMode(newMode.name) }, currentSpeakerId = ttsState.speakerId, onSpeakerChange = { newSpeaker -> ttsController.changeSpeaker(newSpeaker) }, isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER", getAuthToken = { viewModel.getAuthToken() }, bookTitle = epubBook.title ) } TtsWordReplacementsSheet( isVisible = showTtsReplacementsSheet, bookId = bookId, bookTitle = epubBook.title, preferences = ttsReplacementPreferences, onPreferencesChange = updateTtsReplacementPreferences, onDismiss = { showTtsReplacementsSheet = false }, ) ReaderFileInfoDialogs( isFileInfoVisible = showFileInfoDialog, onFileInfoVisibleChange = { showFileInfoDialog = it }, uiState = uiState, primaryBookId = uiState.selectedBookId ?: stableBookId, uriString = uiState.selectedEpubUri?.toString(), viewModel = viewModel ) if (showCustomizeToolsSheet) { CustomizeToolsSheet( hiddenTools = hiddenTools, toolOrder = toolOrder, bottomTools = bottomTools, onUpdate = { newHiddenSet -> hiddenTools = newHiddenSet saveHiddenTools(context, newHiddenSet) }, onOrderUpdate = { newOrder -> toolOrder = newOrder saveToolOrder(context, newOrder) }, onPlacementUpdate = { newBottomTools -> bottomTools = newBottomTools saveBottomTools(context, newBottomTools) }, onDismiss = { showCustomizeToolsSheet = false } ) } if (showBrightnessSheet) { ReaderBrightnessSheet( settings = readerBrightnessSettings, onSettingsChange = updateReaderBrightness, onDismiss = { showBrightnessSheet = false } ) } if (showDictionarySettingsSheet) { DictionarySettingsDialog( isVisible = true, onDismiss = { showDictionarySettingsSheet = false }, isProUser = isProUser, useOnlineDictionary = useOnlineDictionary, onToggleOnlineDictionary = { newState -> useOnlineDictionary = newState saveUseOnlineDict(context, newState) }, selectedDictionaryPackageName = selectedDictPackage, onSelectDictionaryPackage = { pkg -> selectedDictPackage = pkg saveExternalDictPackage(context, pkg) }, selectedTranslatePackageName = selectedTranslatePackage, onSelectTranslatePackage = { pkg -> selectedTranslatePackage = pkg saveExternalTranslatePackage(context, pkg) }, selectedSearchPackageName = selectedSearchPackage, onSelectSearchPackage = { pkg -> selectedSearchPackage = pkg saveExternalSearchPackage(context, pkg) } ) } if (showVisualOptionsSheet) { VisualOptionsSheet( systemUiMode = systemUiMode, onSystemUiModeChange = { systemUiMode = it saveSystemUiMode(context, it) }, pageInfoMode = pageInfoMode, onPageInfoModeChange = { pageInfoMode = it savePageInfoMode(context, it) }, pageInfoPosition = pageInfoPosition, onPageInfoPositionChange = { pageInfoPosition = it savePageInfoPosition(context, it) }, pullToTurnEnabled = pullToTurnEnabled, onPullToTurnChange = { pullToTurnEnabled = it savePullToTurn(context, it) }, pullToTurnMultiplier = pullToTurnMultiplier, onPullToTurnMultiplierChange = { pullToTurnMultiplier = it savePullToTurnMultiplier(context, it) }, onDismiss = { showVisualOptionsSheet = false } ) } if (showScreenOrientationSheet) { ReaderScreenOrientationSheet( selectedMode = screenOrientationMode, onModeSelected = { screenOrientationMode = it saveReaderScreenOrientationMode(context, it) }, onDismiss = { showScreenOrientationSheet = false } ) } if (showFontSelectionSheet) { ModalBottomSheet( onDismissRequest = { showFontSelectionSheet = false }, sheetState = fontSheetState, containerColor = MaterialTheme.colorScheme.surface, contentWindowInsets = { WindowInsets.navigationBars } ) { FontSelectionSheetContent( currentFont = currentFontFamily, currentCustomFontPath = currentCustomFontPath, onFontSelected = { font, path -> currentFontFamily = font currentCustomFontPath = path }, customFonts = customFonts, onImportFont = onImportFont, onDismiss = { showFontSelectionSheet = false } ) Spacer(Modifier.height(16.dp)) } } if (showThemePanel) { ReaderThemePanel( isVisible = true, currentThemeId = currentThemeId, globalTextureTransparency = globalTextureTransparency, onGlobalTextureTransparencyChange = { globalTextureTransparency = it saveGlobalTextureTransparency(context, it) }, onThemeSelected = { currentThemeId = it saveReaderThemeId(context, it) showThemePanel = false }, onDismiss = { showThemePanel = false }, customThemes = customThemes, onCustomThemesUpdated = { customThemes = it; saveCustomThemes(context, it) } ) } if (showInsufficientCreditsDialog) { AlertDialog( onDismissRequest = { showInsufficientCreditsDialog = false }, icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) }, title = { Text(stringResource(R.string.dialog_out_of_credits_title)) }, text = { Text(stringResource(R.string.dialog_out_of_credits_desc)) }, confirmButton = { TextButton(onClick = { showInsufficientCreditsDialog = false onNavigateToPro() }) { Text(stringResource(R.string.action_get_pro_or_add_credits)) } }, dismissButton = { TextButton(onClick = { showInsufficientCreditsDialog = false }) { Text(stringResource(R.string.action_cancel)) } } ) } if (showPaletteManager) { PaletteManagerDialog( currentPalette = currentHighlightPalette, onDismiss = { showPaletteManager = false }, onSave = { newPalette -> newPalette.forEachIndexed { index, color -> onUpdateHighlightPalette(index, color) } showPaletteManager = false } ) } } }