book-reader/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
Aryan f2c5ae25d7
Text lookup upgrade (#66)
* Added `TooltipIconButton` and integrated tooltips for tool icons in app bars

* Added external translate and search support for EPUB and PDF readers

* docs: refine project overview and update build instructions in readme
2026-03-14 13:01:46 +05:30

3815 lines
No EOL
202 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
)
package com.aryan.reader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.net.Uri
import android.os.Build
import android.webkit.WebView
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.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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.WindowCompat
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.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
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.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchAiDefinition
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.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
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 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<Float, Float, Float>? {
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") 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) }
}
@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<CustomFontEntity>,
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
EpubReaderHost(
epubBook = epubBook,
renderMode = renderMode,
initialLocator = initialLocator,
initialCfi = initialCfi,
initialBookmarksJson = initialBookmarksJson,
isProUser = isProUser,
onNavigateBack = onNavigateBack,
onSavePosition = onSavePosition,
onBookmarksChanged = onBookmarksChanged,
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
)
}
@Suppress("ControlFlowWithEmptyBody")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt")
@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?,
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<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null
) {
val view = LocalView.current
val context = LocalContext.current
val window = (view.context as? Activity)?.window
val activity = context as? Activity
val scope = rememberCoroutineScope()
var bannerMessage by remember { mutableStateOf<BannerMessage?>(null) }
val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToPosition by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
val volumeScrollFocusDebounceJob = remember { mutableStateOf<Job?>(null) }
var sliderStartPage by remember { mutableIntStateOf(0) }
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
var chunkTargetOverride by remember { mutableStateOf<Int?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
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)) }
val locatorConverter = remember(context) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
proto = ProtoBuf { serializersModule = semanticBlockModule },
context = context
)
}
val userHighlights = remember {
mutableStateListOf<UserHighlight>().apply {
addAll(loadHighlightsFromPrefs(context, epubBook.title))
}
}
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
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)
}
}
LaunchedEffect(userHighlights.size, userHighlights.toList()) {
saveHighlightsToPrefs(context, epubBook.title, userHighlights)
}
// Dictionary
var showAiDefinitionPopup by remember { mutableStateOf(false) }
var selectedTextForAi by remember { mutableStateOf<String?>(null) }
var aiDefinitionResult by remember { mutableStateOf<AiDefinitionResult?>(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 showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(word)
if (isProUser || wordCount <= 1) {
selectedTextForAi = word
showAiDefinitionPopup = true
scope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = word,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = { isAiDefinitionLoading = false }
)
}
} else {
showDictionaryUpsellDialog = true
}
} else {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word)
} else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
}
val onTranslateLookup = { text: String ->
if (!selectedTranslatePackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchTranslate(context, selectedTranslatePackage!!, text)
} else {
Toast.makeText(context, "Please select a translate app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val onSearchLookup = { text: String ->
if (!selectedSearchPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchSearch(context, selectedSearchPackage!!, text)
} else {
Toast.makeText(context, "Please select a search app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isRecapLoading by remember { mutableStateOf(false) }
var recapProgressMessage by remember { mutableStateOf("") }
var isRequestingRecapCfi by remember { mutableStateOf(false) }
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
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...")
val stringSet = bookmarks.map { bookmark ->
JSONObject().apply {
put("cfi", bookmark.cfi)
put("chapterTitle", bookmark.chapterTitle)
put("label", bookmark.label)
put("snippet", bookmark.snippet)
bookmark.pageInChapter?.let { put("pageInChapter", it) }
bookmark.totalPagesInChapter?.let { put("totalPagesInChapter", it) }
put("chapterIndex", bookmark.chapterIndex)
}.toString()
}
onBookmarksChanged(JSONArray(stringSet).toString())
}
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(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 skipChapterRequest by remember { mutableStateOf(false) }
var ttsChapterIndex by remember { mutableStateOf<Int?>(null) }
var searchHighlightTarget by remember { mutableStateOf<SearchResult?>(null) }
var lastHighlightClickTime by remember { mutableLongStateOf(0L) }
var webViewRefForTts by remember { mutableStateOf<WebView?>(null) }
var showSummarizationPopup by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isSummarizationLoading 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 currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf(
initialLocator?.chapterIndex?.coerceIn(0, chapters.size - 1) ?: 0
)
}
var paginator by remember { mutableStateOf<IPaginator?>(null) }
val paginatedPagerState = rememberPagerState(pageCount = {
(paginator as? BookPaginator)?.totalPageCount ?: 0
})
val ttsController = rememberTtsController()
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<List<String>>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf<String?>(null) }
var fragmentToLoad by remember { mutableStateOf<String?>(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
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<String?>(null) }
val density = LocalDensity.current
val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() }
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 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, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
if (isFormatLocal) {
saveLocalReaderSettings(
context, bookId, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
)
} else {
saveReaderSettings(
context, currentFontSizeEm, currentLineHeight, currentFontFamily, currentCustomFontPath, currentTextAlign
)
}
}
LaunchedEffect(bannerMessage) {
if (bannerMessage != null) {
delay(2500L)
bannerMessage = null
}
}
val configuration = androidx.compose.ui.platform.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(isPageSliderVisible) {
if (!isPageSliderVisible) {
startPageThumbnail?.recycle()
startPageThumbnail = null
}
}
LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message ->
bannerMessage = BannerMessage(message, isError = true)
}
}
LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) {
skipChapterRequest = false
if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) {
Timber.d("Executing skip chapter request for continuous TTS.")
currentChapterIndex++
} else {
ttsShouldStartOnChapterLoad = false
}
}
}
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) }
var isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) }
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
var isMusicianMode by remember { mutableStateOf(loadMusicianMode(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadAutoScrollUseSlider(context)) }
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)
}
}
fun startTts() {
if (isAutoScrollModeActive) {
isAutoScrollModeActive = false
isAutoScrollPlaying = false
}
Timber.d("TTS button clicked: Starting TTS")
userStoppedTts = false
initiateTtsPlayback(
renderMode = currentRenderMode,
webView = webViewRefForTts,
onPaginatedStart = {
scope.launch {
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 ttsChunks = bookPaginator.getTtsChunksForChapter(
chapterIndex = chapterIndex,
startingFromPageInChapter = pageInChapter
)
if (!ttsChunks.isNullOrEmpty()) {
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex
ttsController.start(
chunks = ttsChunks,
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
}
}
}
}
)
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { _ ->
startTts()
}
)
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
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 ->
initialScrollTargetForChapter = ChapterScrollPosition.START
cfiToLoad = null
currentScrollYPosition = 0
currentChapterIndex = nextIndex
},
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts,
scope = scope,
currentTtsMode = currentTtsMode
)
TtsHighlightHandler(
ttsState = ttsState,
currentRenderMode = currentRenderMode,
webViewRef = webViewRefForTts,
paginator = paginator,
pagerState = paginatedPagerState,
ttsChapterIndex = ttsChapterIndex,
scope = scope
)
EpubReaderSearchEffects(
searchState = searchState,
webViewRef = webViewRefForTts,
currentChapterIndex = currentChapterIndex,
focusRequester = searchFocusRequester
)
if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Error: Book content not found. Path: ${epubBook.extractionBasePath}",
color = MaterialTheme.colorScheme.error
)
}
return
}
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
}
}
val latestChapterIndex by rememberUpdatedState(currentChapterIndex)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, webViewRefForTts) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
Timber.d("ON_PAUSE detected. Requesting final CFI for robust save.")
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
DisposableEffect(Unit) {
onDispose {
Timber.d("Disposing reader. Last known chapter was ${latestChapterIndex}. Position saved periodically.")
}
}
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 ->
showRecapPopup = true
isRecapLoading = true
recapResult = null
recapProgressMessage = "Checking past chapters..."
scope.launch {
executeRecapLogic(
epubBook = epubBook,
chapterIndex = chapterIdx,
characterLimit = charLimit,
summaryCacheManager = summaryCacheManager,
paginator = paginator,
onProgressUpdate = { recapProgressMessage = it },
onResultUpdate = { chunk ->
isRecapLoading = false // Start showing content
val current = recapResult?.summary ?: ""
recapResult = SummarizationResult(summary = current + chunk)
},
onError = { error ->
recapResult = SummarizationResult(error = error)
},
onFinish = { isRecapLoading = false }
)
}
}
LaunchedEffect(currentChapterIndex) {
isChapterParsing = true
isChapterReadyForBookmarkCheck = false
activeFragmentId = null
val result = loadChapterContent(
epubBook = epubBook,
chapterIndex = currentChapterIndex,
chunkTargetOverride = chunkTargetOverride,
isInitialCfiLoad = isInitialCfiLoad,
cfiToLoad = cfiToLoad,
locatorConverter = locatorConverter
)
chapterHead = result.head
chapterChunks = result.chunks
isChapterParsing = false
loadUpToChunkIndex = result.startChunkIndex
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
if (chunkTargetOverride != null) {
chunkTargetOverride = null
}
if (isInitialCfiLoad) {
isInitialCfiLoad = false
}
loadedChunkCount = 1
topVisibleChunkIndex = 0
}
EpubReaderSystemUiController(
window = window,
view = view,
showBars = showBars,
initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars,
initialSystemBarsBehavior = initialSystemBarsBehavior
)
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
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.currentPage, paginator) {
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
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.")
}
}
}
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.")
}
}
}
}
val currentChapterInPaginatedMode by remember {
derivedStateOf {
if (currentRenderMode == RenderMode.PAGINATED) {
(paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
} else {
null
}
}
}
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 locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
val chapterIndex = paginator?.findChapterIndexForPage(pageToSave)
if (locator != null && chapterIndex != null) {
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("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 or chapter index is null."
)
}
onNavigateBack()
}
return
}
}
scope.launch {
delay(1500L)
if (isSavingAndExiting) {
Timber.w("CFI save on exit timed out. Navigating back.")
onNavigateBack()
}
}
}
}
fun navigateToSearchResult(index: Int) {
performSearchResultNavigation(
index = index,
searchState = searchState,
renderMode = currentRenderMode,
currentChapterIndex = currentChapterIndex,
loadedChunkCount = loadedChunkCount,
webView = webViewRefForTts,
paginator = paginator,
coroutineScope = scope,
onVerticalChapterChange = { chapterIdx, chunkIdx, result ->
initialScrollTargetForChapter = ChapterScrollPosition.START
currentChapterIndex = chapterIdx
searchHighlightTarget = result
loadUpToChunkIndex = chunkIdx
},
onVerticalScrollToResult = { _ ->
searchHighlightTarget = null
},
onPaginatedScrollToPage = { pageIdx ->
paginatedPagerState.scrollToPage(pageIdx)
}
)
}
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 (isPageSliderVisible) {
isPageSliderVisible = false
showBars = true
} else 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, // Pass TOC
activeFragmentId = activeFragmentId,
bookmarks = bookmarks,
userHighlights = userHighlights,
currentChapterIndex = currentChapterIndex,
currentChapterInPaginatedMode = currentChapterInPaginatedMode,
renderMode = currentRenderMode,
onNavigateToTocEntry = { entry ->
scope.launch {
drawerState.close()
val targetChapterIndex = chapters.indexOfFirst { it.absPath == entry.absolutePath }
if (targetChapterIndex != -1) {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
fragmentToLoad = entry.fragmentId
if (targetChapterIndex != currentChapterIndex) {
initialScrollTargetForChapter = null
currentScrollYPosition = 0
currentChapterIndex = targetChapterIndex
} else {
if (entry.fragmentId != null) {
webViewRefForTts?.evaluateJavascript(
"javascript:var el = document.getElementById('${entry.fragmentId}'); if(el) { el.scrollIntoView(); }",
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
bookPaginator.findPageForAnchor(targetChapterIndex, entry.fragmentId) { targetPage ->
scope.launch {
Timber.tag("TOC_NAV_DEBUG").d("Scrolling Pager to page: $targetPage")
paginatedPagerState.scrollToPage(targetPage)
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) {
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentChapterIndex = 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) {
val targetPage = bookPaginator.chapterStartPageIndices[index]
if (targetPage != null) {
paginatedPagerState.scrollToPage(targetPage)
if (showBars) showBars = false
}
}
}
}
}
}
},
onNavigateToBookmark = { bookmark ->
scope.launch {
drawerState.close()
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi
val directChunkIndex = try {
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() }
if (parts.isNotEmpty()) {
val firstIndex = parts[0]
(firstIndex - 2) / 2
} else null
} catch (_: Exception) {
null
}
val locator = if (directChunkIndex == null) {
locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
} else {
null
}
val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 }
if (bookmark.chapterIndex != currentChapterIndex) {
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
targetChunk
} else {
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 -> {
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
isNavigatingToPosition = true
val locator = locatorConverter.getLocatorFromCfi(
book = epubBook,
chapterIndex = bookmark.chapterIndex,
cfi = bookmark.cfi
)
if (locator != null) {
Timber.d("P-Mode Click: Successfully converted CFI to Locator: $locator")
val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator)
if (pageIndex != null) {
Timber.d("P-Mode Click: Paginator found page $pageIndex for locator. Scrolling.")
paginatedPagerState.scrollToPage(pageIndex)
} else {
Timber.w("P-Mode Click: Paginator could not find a page for the locator. Falling back to chapter start.")
val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(bookmark.chapterIndex)
if (chapterStartPage != null) {
paginatedPagerState.scrollToPage(chapterStartPage)
}
}
isNavigatingToPosition = false
} else {
Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.")
paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex ->
scope.launch {
paginatedPagerState.scrollToPage(pageIndex)
isNavigatingToPosition = false
}
}
}
}
}
if (showBars) {
showBars = false
}
}
},
onNavigateToHighlight = { highlight ->
scope.launch {
drawerState.close()
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
cfiToLoad = highlight.cfi
val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
val targetChunk = locator?.let { it.blockIndex / 20 }
if (highlight.chapterIndex != currentChapterIndex) {
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 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 -> {
isNavigatingToPosition = true
val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
if (locator != null) {
val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator)
if (pageIndex != null) {
paginatedPagerState.scrollToPage(pageIndex)
} else {
val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(highlight.chapterIndex)
if (chapterStartPage != null) {
paginatedPagerState.scrollToPage(chapterStartPage)
}
}
isNavigatingToPosition = false
} else {
paginator?.findPageForCfi(highlight.chapterIndex, highlight.cfi) { pageIndex ->
scope.launch {
paginatedPagerState.scrollToPage(pageIndex)
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 cfiParts = highlightToDelete.cfi.split("|")
cfiParts.forEach { partCfi ->
val jsCommand = "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClass');"
Timber.d("Executing JS removal for part: $partCfi")
webViewRefForTts?.evaluateJavascript(jsCommand, null)
}
}
}
)
}
) {
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.")
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets.statusBars,
) { scaffoldPaddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPaddingValues)
.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 {
initialScrollTargetForChapter = target
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
currentChapterIndex += offset
}
},
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 -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (showBars || showFormatAdjustmentBars) 0.dp else PAGE_INFO_BAR_HEIGHT)
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.testTag("ReaderContainer")
) {
if (chapters.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No chapters available for this book.")
}
} else {
AnimatedContent(
targetState = currentChapterIndex,
transitionSpec = {
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()) {
val initialContentToLoad =
remember(loadUpToChunkIndex, chapterChunks) {
val startIdx = maxOf(0, loadUpToChunkIndex - 1)
val endIdx = minOf(chapterChunks.lastIndex, loadUpToChunkIndex + 1)
chapterChunks.indices.joinToString(separator = "\n") { index ->
if (index in startIdx..endIdx) {
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
} else {
"<div class='chunk-container' data-chunk-index='$index'></div>"
}
}
}
val initialHtml = """
<!DOCTYPE html>
<html>
<head>
$chapterHead
</head>
<body>
<div id="content-top-sentinel" style="height: 1px; width: 100%;"></div>
<div id="content-container">
$initialContentToLoad
</div>
<div id="content-bottom-sentinel" style="height: 1px; width: 100%;"></div>
</body>
</html>
""".trimIndent()
val chapterToRender = chapters[targetChapterIndex]
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) {
val target = searchHighlightTarget
Timber.d("Effect(isWebViewReady=$isWebViewReady) triggered for chapter $targetChapterIndex. Target is: $target"
)
if (isWebViewReady && target != null && target.locationInSource == targetChapterIndex) {
Timber.d("Highlighting condition met. Highlighting now."
)
delay(200)
val webView = webViewRefForTts
if (webView != null) {
val escapedQuery = escapeJsString(target.query)
val js =
"javascript:window.highlightAllOccurrences('${escapedQuery}'); window.scrollToOccurrence(${target.occurrenceIndexInLocation});"
Timber.d("Executing search highlight/scroll JS: $js"
)
webView.evaluateJavascript(js) { result ->
Timber.d("JS highlight/scroll result: $result"
)
}
searchHighlightTarget = null
} else {
Timber.w("Highlight failed: WebView was null even after ready signal."
)
searchHighlightTarget = null
}
}
}
val currentChapterTocFragments = remember(epubBook.tableOfContents, currentChapterIndex) {
val chapterPath = chapters.getOrNull(currentChapterIndex)?.absPath
epubBook.tableOfContents
.filter { it.absolutePath == chapterPath && it.fragmentId != null }
.mapNotNull { it.fragmentId }
}
@Suppress("KotlinConstantConditions",
"ControlFlowWithEmptyBody"
)
ChapterWebView(
key = chapterKeyForWebView,
chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme,
initialScrollTarget = initialScrollTargetForChapter,
initialPageScrollY = currentScrollYPosition,
initialCfi = cfiToLoad,
initialFragmentId = fragmentToLoad.also { },
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 existingIndex = userHighlights.indexOfFirst { it.cfi == cfi }
if (existingIndex != -1) {
val existing = userHighlights[existingIndex]
userHighlights[existingIndex] = existing.copy(color = color, text = text)
Timber.d("Kotlin: Updated existing highlight at index $existingIndex")
} else {
val highlight = UserHighlight(
cfi = cfi,
text = text,
color = color,
chapterIndex = currentChapterIndex
)
userHighlights.add(highlight)
Timber.d("Kotlin: Added new highlight")
}
},
onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) {
userHighlights.remove(toRemove)
Timber.d("Deleted highlight: $cfi")
}
},
onChapterInitiallyScrolled = {
val wasCfiScroll = cfiToLoad != null
initialScrollTargetForChapter = null
cfiToLoad = null
fragmentToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
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) {
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
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 (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) {
showBars = false
showFormatAdjustmentBars = false
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) {
Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).")
initialScrollTargetForChapter = ChapterScrollPosition.START
currentScrollYPosition = 0
currentChapterIndex++
isAutoScrollPlaying = true
} else {
Timber.d("Screen: Reached end of book. Stopping auto-scroll.")
isAutoScrollPlaying = false
}
}
},
onOverScrollTop = { dragAmount ->
if (targetChapterIndex > 0) {
pullToPrevProgress =
dragAmount / dragThresholdPx
}
},
onOverScrollBottom = { dragAmount ->
if (targetChapterIndex < chapters.size - 1) {
pullToNextProgress =
dragAmount / dragThresholdPx
}
},
onReleaseOverScrollTop = {
if (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 {
delay(50)
initialScrollTargetForChapter =
ChapterScrollPosition.END
currentChapterIndex--
if (showBars) showBars = false
Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END"
)
}
}
pullToPrevProgress = 0f
},
onReleaseOverScrollBottom = {
if (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 {
delay(50)
initialScrollTargetForChapter =
ChapterScrollPosition.START
currentScrollYPosition = 0
currentChapterIndex++
if (showBars) showBars = false
}
}
pullToNextProgress = 0f
},
tocFragments = currentChapterTocFragments,
onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId ->
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,
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign,
onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis()
showBars = false
showFormatAdjustmentBars = false
Timber.d("Highlight clicked - Forcing bars hidden")
},
onWebViewInstanceCreated = { webView ->
webViewRefForTts = webView
webView.evaluateJavascript(
"javascript:window.setViewportPadding(${topPaddingPx}, 0);",
null
)
},
onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToPosition = false
},
ttsScope = scope,
onTtsTextReady = { jsonString ->
scope.launch {
Timber.d("Vertical: onTtsTextReady received JSON. Length: ${jsonString.length}")
val ttsChunks =
mutableListOf<TtsChunk>()
try {
val jsonArray = JSONArray(jsonString)
Timber.d("Vertical: Parsed JSON Array. Items: ${jsonArray.length()}")
for (i in 0 until jsonArray.length()) {
val jsonObject =
jsonArray.getJSONObject(i)
val text = jsonObject.getString("text")
val cfiJsonString =
jsonObject.getString("cfi")
val cfiJsonObject =
JSONObject(cfiJsonString)
val cfi = cfiJsonObject.getString("cfi")
val subChunks =
splitTextIntoChunks(text)
var currentOffset = 0
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}")
if (ttsChunks.isNotEmpty()) {
ttsShouldStartOnChapterLoad = false
val chapterTitle =
chapters.getOrNull(currentChapterIndex)?.title
val coverUriString = coverImagePath?.let {
Uri.fromFile(File(it)).toString()
}
ttsChapterIndex = currentChapterIndex
ttsController.start(
chunks = ttsChunks,
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
} else {
Timber.w("No TTS chunks were created from JSON, not starting TTS."
)
if (ttsShouldStartOnChapterLoad) {
Timber.d("Empty chapter detected during continuous TTS. Requesting skip."
)
skipChapterRequest = true
}
}
}
},
onWordSelectedForAiDefinition = { text ->
onDictionaryLookup(text)
},
onTranslate = { text ->
onTranslateLookup(text)
},
onSearch = { text ->
onSearchLookup(text)
},
onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization")
scope.launch {
val chapterIndexToSave = currentChapterIndex
val bookTitleToSave = epubBook.title
val finalSummaryBuilder = StringBuilder()
summarizeBookContent(
content = content,
onUpdate = { chunk ->
finalSummaryBuilder.append(chunk)
val currentSummary = summarizationResult?.summary ?: ""
summarizationResult = SummarizationResult(summary = currentSummary + chunk)
},
onError = { error ->
summarizationResult = SummarizationResult(error = error)
},
onFinish = {
isSummarizationLoading = false
val fullSummary = finalSummaryBuilder.toString()
if (fullSummary.isNotBlank()) {
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, fullSummary)
}
}
)
}
},
isProUser = isProUser,
isOss = BuildConfig.FLAVOR == "oss",
onShowDictionaryUpsellDialog = {
showDictionaryUpsellDialog = true
},
onCfiGenerated = { cfi ->
Timber.tag("PosSaveDiag").d("EpubReaderScreen: onCfiGenerated callback triggered with CFI: '$cfi'")
if (cfi.isBlank() || !cfi.startsWith('/')) {
Timber.tag("PosSaveDiag").w("EpubReaderScreen: onCfiGenerated received an invalid CFI, aborting save: '$cfi'")
if (isSavingAndExiting) {
isSavingAndExiting = false
onNavigateBack()
}
return@ChapterWebView
}
scope.launch {
Timber.tag("PosSaveDiag").d("EpubReaderScreen: Requesting locator conversion for chapter $latestChapterIndex")
val locator =
locatorConverter.getLocatorFromCfi(
epubBook,
latestChapterIndex,
cfi
)
Timber.tag("PosSaveDiag").d("EpubReaderScreen: Locator conversion returned: $locator")
if (locator != null) {
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.d("CFI received for saving: $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
?: "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 ->
topVisibleChunkIndex = chunkIndex
},
initialHtmlContent = initialHtml,
baseUrl = baseUrl,
totalChunks = chapterChunks.size,
initialChunkIndex = loadUpToChunkIndex,
onChunkRequested = { index ->
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 (currentChapterIndex > 0) {
ChapterChangeIndicator(
text = "Release for Previous Chapter",
progress = pullToPrevProgress,
isPullingDown = true,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
)
}
if (currentChapterIndex < chapters.size - 1) {
ChapterChangeIndicator(
text = "Release for Next Chapter",
progress = pullToNextProgress,
isPullingDown = false,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp)
)
}
}
}
}
RenderMode.PAGINATED -> {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(bottom = PAGE_INFO_BAR_HEIGHT)
.testTag("ReaderContainer")
) {
@Suppress("KotlinConstantConditions")
PaginatedReaderScreen(
book = epubBook,
isDarkTheme = isDarkTheme,
pagerState = paginatedPagerState,
searchQuery = searchState.searchQuery,
fontSizeMultiplier = currentFontSizeEm,
lineHeightMultiplier = currentLineHeight,
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 },
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 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 = (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 = (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)
},
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
onHighlightCreated = { cfi, text, colorId ->
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
processAndAddHighlight(
newCfi = cfi,
newText = text,
newColor = color,
chapterIndex = currentChapterInPaginatedMode ?: 0,
currentList = userHighlights
)
},
onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) {
userHighlights.remove(toRemove)
}
}
)
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 } ?: "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 = renderMode == RenderMode.VERTICAL_SCROLL && !showBars,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f))
.padding(bottom = bottomPadding)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
val chapterTitle =
chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim()
?: "Chapter"
Text(
text = "$chapterTitle ($currentPageInChapter/$totalPagesInCurrentChapter)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 48.dp)
)
if (totalBookLengthChars > 0) {
Text(
text = "%.1f%%".format(currentBookProgress),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
// Page Info Bar (Paginated)
AnimatedVisibility(
visible = renderMode == RenderMode.PAGINATED && paginator != null && !showBars && paginatedPagerState.pageCount > 0,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f))
.padding(bottom = bottomPadding)
.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()
?: "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 {
"Page ${paginatedPagerState.currentPage + 1}/${paginatedPagerState.pageCount}"
}
Text(
text = textToShow,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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 = MaterialTheme.colorScheme.onSurfaceVariant,
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 = MaterialTheme.colorScheme.onSurfaceVariant,
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,
tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
onNavigateBack = { triggerSaveAndExit() },
onCloseSearch = {
searchState.isSearchActive = false
searchState.onQueryChange("")
keyboardController?.hide()
focusManager.clearFocus()
containerFocusRequester.requestFocus()
},
onChangeRenderMode = { newMode ->
if (newMode != currentRenderMode) {
if (newMode == RenderMode.PAGINATED) {
isSwitchingToPaginated = true
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
} else {
scope.launch {
lastKnownLocator?.let { locator ->
val cfi = locatorConverter.getCfiFromLocator(epubBook, locator)
if (cfi != null) {
val targetChunk = locator.blockIndex / 20
chunkTargetOverride = targetChunk
if (currentChapterIndex != locator.chapterIndex) {
currentChapterIndex = locator.chapterIndex
}
cfiToLoad = cfi
} else {
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)
},
onToggleVolumeScroll = { enabled ->
volumeScrollEnabled = enabled
saveVolumeScrollSetting(context, enabled)
},
onStartAutoScroll = {
isAutoScrollModeActive = true
isAutoScrollPlaying = true
showBars = !isMusicianMode
},
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
currentChapterInPaginatedMode ?: currentChapterIndex
} else {
currentChapterIndex
}
onToggleReflow(activeChapter)
}
} else null,
onDeleteReflow = onDeleteReflow
)
val autoScrollPadding by androidx.compose.animation.core.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 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
)
}
// Animated Bottom Bar
EpubReaderBottomBar(
isVisible = showBars,
currentRenderMode = currentRenderMode,
isTtsSessionActive = isTtsSessionActive,
ttsState = ttsState,
isProUser = isProUser,
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
sliderStartPage = currentPageInChapter
sliderCurrentPage = currentPageInChapter.toFloat()
isPageSliderVisible = true
showBars = false
scope.launch {
webViewRefForTts?.let { webView ->
startPageThumbnail = captureWebViewVisibleArea(webView)
}
}
}
RenderMode.PAGINATED -> {
if (paginatedPagerState.pageCount > 0) {
sliderStartPage = paginatedPagerState.currentPage + 1
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
isPageSliderVisible = true
showBars = false
startPageThumbnail = null
} else {
bannerMessage = BannerMessage("Book is not paginated yet.")
}
}
}
},
onOpenDrawer = {
scope.launch { drawerState.open() }
},
onToggleFormat = {
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false
isPageSliderVisible = false
}
},
onToggleSearch = {
searchState.isSearchActive = true
searchState.showSearchResultsPanel = true
showBars = true
showFormatAdjustmentBars = false
},
onSummarize = {
if (isProUser) {
showSummarizationPopup = true
isSummarizationLoading = true
summarizationResult = null
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result ->
Timber.d("JS summarization request: $result")
} ?: run {
isSummarizationLoading = false
summarizationResult = SummarizationResult(error = "WebView not available.")
}
}
RenderMode.PAGINATED -> {
scope.launch {
val currentPage = paginatedPagerState.currentPage
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage)
if (chapterIndex != null) {
val text = paginator?.getPlainTextForChapter(chapterIndex)
if (!text.isNullOrBlank()) {
summarizeBookContent(
content = text,
onUpdate = { chunk ->
val currentSummary =
summarizationResult?.summary
?: ""
summarizationResult =
SummarizationResult(
summary = currentSummary + chunk
)
},
onError = { error ->
summarizationResult =
SummarizationResult(
error = error
)
},
onFinish = {
isSummarizationLoading =
false
}
)
} else {
summarizationResult = SummarizationResult(error = "Could not get chapter content.")
isSummarizationLoading = false
}
} else {
summarizationResult = SummarizationResult(error = "Could not determine current chapter.")
isSummarizationLoading = false
}
}
}
}
} else {
showSummarizationUpsellDialog = true
}
},
onRecap = {
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)
Timber.d("Paginated Mode: Chapter $chapterIndex, PageInChapter $currentPageInChapter")
Timber.d("Paginated Mode: Chars Scrolled (Limit): $charsScrolled")
runRecap(chapterIndex, charsScrolled.toInt())
} else {
bannerMessage = BannerMessage("Wait for book to load fully.", isError = true)
}
}
}
},
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)
}
}
}
},
onPlayPauseTts = {
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding)
)
ReaderTextFormatPanel(
isVisible = showFormatAdjustmentBars,
currentFontSize = currentFontSizeEm,
onFontSizeChange = { currentFontSizeEm = it },
currentLineHeight = currentLineHeight,
onLineHeightChange = { currentLineHeight = it },
currentFont = currentFontFamily,
currentCustomFontName = if(currentCustomFontPath != null) {
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
} 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
currentFontFamily = ReaderFont.ORIGINAL
currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT
},
isLocalMode = isFormatLocal,
onLocalModeToggle = {
isFormatLocal = it
saveFormatIsLocal(context, bookId, it)
},
onClose = { showFormatAdjustmentBars = false },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding + 16.dp)
.padding(horizontal = 16.dp)
)
EpubReaderAiOverlays(
showSummarizationPopup = showSummarizationPopup,
summarizationResult = summarizationResult,
isSummarizationLoading = isSummarizationLoading,
onDismissSummarization = {
showSummarizationPopup = false
isSummarizationLoading = false
summarizationResult = null
},
showSummarizationUpsellDialog = showSummarizationUpsellDialog,
onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false },
showRecapPopup = showRecapPopup,
recapResult = recapResult,
isRecapLoading = isRecapLoading,
onDismissRecap = {
showRecapPopup = false
isRecapLoading = false
recapResult = null
},
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
}
}
)
if (isNavigatingToPosition) {
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 = "Navigating to position...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
if (showPermissionRationaleDialog) {
AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false },
title = { Text("Permission Required") },
text = { Text("To show playback controls while the app is in the background, please grant the notification permission.") },
confirmButton = {
TextButton(
onClick = {
showPermissionRationaleDialog = false
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
) {
Text("Continue")
}
},
dismissButton = {
TextButton(
onClick = {
showPermissionRationaleDialog = false
startTts()
}
) {
Text("Not now")
}
}
)
}
if (showJustifyWarningDialog) {
AlertDialog(
onDismissRequest = { showJustifyWarningDialog = false },
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Justified Text Limitation") },
text = { Text("Using Justified alignment in Paginated Mode may cause text selection and highlights to be inaccurate due to layout limitations.") },
confirmButton = {
TextButton(onClick = { showJustifyWarningDialog = false }) {
Text("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(
"Navigating to chapter...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
CustomTopBanner(bannerMessage = bannerMessage)
}
}
EpubReaderPageSlider(
isVisible = isPageSliderVisible,
currentRenderMode = currentRenderMode,
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
sliderCurrentPage = sliderCurrentPage,
sliderStartPage = sliderStartPage,
startPageThumbnail = startPageThumbnail,
paginator = paginator,
chapters = chapters,
onClose = {
isPageSliderVisible = false
showBars = true
},
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()
paginatedPagerState.scrollToPage(page - 1)
}
}
}
)
if (isPageSliderVisible && 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"
)
}
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 (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = 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))
}
}
}
}