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