diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3baec64 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +app/src/main/cpp/libmobi/** linguist-vendored +app/src/main/cpp/woff2/** linguist-vendored \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7015c5d..b0cb028 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -225,6 +225,8 @@ dependencies { implementation("io.legere:pdfiumandroid:2.0.0") implementation("org.zwobble.mammoth:mammoth:1.4.2") + + implementation("com.materialkolor:material-kolor:5.0.0-alpha07") } spotless { diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 9b907b7..ba8d471 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -442,14 +442,70 @@ return false; } + function getFootnoteContent(targetId) { + console.log("FootnoteDiag: Searching for footnote content with id: '" + targetId + "'"); + + var el = document.getElementById(targetId); + if (el) { + console.log("FootnoteDiag: Found element directly in DOM."); + return el.innerHTML; + } + + console.log("FootnoteDiag: Element not in DOM, checking virtualized chunks."); + 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) { + console.log("FootnoteDiag: Found potential id match in chunk " + i); + var tempDiv = document.createElement('div'); + tempDiv.innerHTML = chunkHtml; + var found = tempDiv.querySelector('#' + targetId); + if (found) { + console.log("FootnoteDiag: Successfully extracted note from chunk " + i + "."); + return found.innerHTML; + } + } + } + } + console.log("FootnoteDiag: Footnote content not found anywhere."); + return null; + } + // 1. Handle Taps (Click) - document.addEventListener( - "click", - function (e) { - handleHighlightInteraction(e); - }, - true - ); + document.addEventListener("click", function (e) { + if (handleHighlightInteraction(e)) return; + + var target = e.target; + var anchor = target.closest('a'); + + if (anchor) { + var href = anchor.getAttribute('href'); + var epubType = anchor.getAttribute('epub:type'); + + console.log("FootnoteDiag: Link clicked. href: '" + href + "', epub:type: '" + epubType + "'"); + + if ((href && href.startsWith('#')) || epubType === 'noteref') { + var targetId = href ? href.substring(1) : null; + console.log("FootnoteDiag: Extracted targetId: '" + targetId + "'"); + + if (targetId) { + var content = getFootnoteContent(targetId); + if (content && window.FootnoteBridge) { + console.log("FootnoteDiag: Content extracted, sending to Kotlin Bridge."); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + window.FootnoteBridge.onFootnoteRequested(content); + return; + } else if (!content) { + console.log("FootnoteDiag: Failed to get content. Link might just be a regular anchor."); + } else if (!window.FootnoteBridge) { + console.log("FootnoteDiag: window.FootnoteBridge is undefined!"); + } + } + } + } + }, true); window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) { var logTag = "ReaderFontDiagnosis"; @@ -1094,13 +1150,14 @@ }; window.extractTextWithCfiFromTop = function () { + console.log("TTS_CHAPTER_CHANGE_DIAG: Starting extractTextWithCfiFromTop"); try { const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote"; const allContentNodesRaw = Array.from(document.body.querySelectorAll(ttsNodeSelector)); - - // FIX: Filter out parents that contain matching children to prevent duplicates const allContentNodes = allContentNodesRaw.filter(node => node.querySelector(ttsNodeSelector) === null); + console.log("TTS_CHAPTER_CHANGE_DIAG: Total nodes found: " + allContentNodes.length); + let startBlock = null; let startIndex = -1; @@ -1116,9 +1173,12 @@ } if (!startBlock) { + console.log("TTS_CHAPTER_CHANGE_DIAG: No visible start block found in viewport."); return window.extractTextWithCfi(); } + console.log("TTS_CHAPTER_CHANGE_DIAG: Starting extraction from node index: " + startIndex); + const nodesToProcess = allContentNodes.slice(startIndex); const results =[]; @@ -1136,6 +1196,7 @@ return JSON.stringify(results); } catch (e) { + console.log("TTS_CHAPTER_CHANGE_DIAG: Error in extractTextWithCfiFromTop: " + e.message); return window.extractTextWithCfi(); } }; @@ -1240,13 +1301,20 @@ window.TtsBridgeHelper = { extractAndRelayText: function () { + const traceId = Date.now(); + console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] extractAndRelayText invoked."); try { const structuredTextJson = window.extractTextWithCfiFromTop(); + const len = structuredTextJson ? structuredTextJson.length : 0; + console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] Relay text JSON length: " + len); if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) { TtsBridge.onStructuredTextExtracted(structuredTextJson); + } else { + console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] Bridge missing!"); } } catch (e) { + console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] Error: " + e.message); if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) { TtsBridge.onStructuredTextExtracted("[]"); } diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 77b7433..2b9b434 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -31,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -44,6 +45,7 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -55,7 +57,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud @@ -116,6 +120,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -125,6 +131,7 @@ import androidx.compose.ui.text.font.FontWeight 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.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import coil.compose.AsyncImage @@ -151,6 +158,7 @@ fun HomeScreen( val context = LocalContext.current val customTabUriHandler = remember { CustomTabUriHandler(context) } var showCloseAllTabsDialog by remember { mutableStateOf(false) } + var showAppThemePanel by remember { mutableStateOf(false) } CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -326,7 +334,8 @@ fun HomeScreen( } else { showStrictFilterDialog = true } - } + }, + onAppThemeClick = { showAppThemePanel = true } ) } else { ContextualTopAppBar( @@ -499,6 +508,19 @@ fun HomeScreen( onDismiss = { showStrictFilterDialog = false } ) } + + if (showAppThemePanel) { + AppThemeBottomSheet( + uiState = uiState, + onThemeModeChanged = viewModel::setAppThemeMode, + onContrastOptionChanged = viewModel::setAppContrastOption, + onTextDimFactorChanged = viewModel::setAppTextDimFactor, + onSeedColorChanged = viewModel::setAppSeedColor, + onCustomThemeAdded = viewModel::addCustomAppTheme, + onCustomThemeDeleted = viewModel::deleteCustomAppTheme, + onDismiss = { showAppThemePanel = false } + ) + } } } if (showAboutDialog) { @@ -840,11 +862,7 @@ fun RecentFileCard( verticalArrangement = Arrangement.Center ) { Text( - text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.FB2) && !item.title.isNullOrBlank()) { - item.title - } else { - item.displayName - }, + text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -883,7 +901,8 @@ fun DefaultTopAppBar( onRecentFilesLimitChange: (Int) -> Unit, onTabsToggle: (Boolean) -> Unit, onExternalFileBehaviorClick: () -> Unit, - onStrictFilterToggleClick: () -> Unit + onStrictFilterToggleClick: () -> Unit, + onAppThemeClick: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -900,6 +919,11 @@ fun DefaultTopAppBar( } } }, actions = { + Box { + IconButton(onClick = onAppThemeClick) { + Icon(painterResource(id = R.drawable.palette), contentDescription = "App Theme") + } + } // Recent Files Limit Menu Box { IconButton(onClick = { showLimitMenu = true }) { @@ -1541,4 +1565,354 @@ fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) confirmButton = { TextButton(onClick = onConfirm) { Text("Enable") } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppThemeBottomSheet( + uiState: ReaderScreenState, + onThemeModeChanged: (AppThemeMode) -> Unit, + onContrastOptionChanged: (AppContrastOption) -> Unit, + onTextDimFactorChanged: (Float) -> Unit, + onSeedColorChanged: (Color?) -> Unit, + onCustomThemeAdded: (CustomAppTheme) -> Unit, + onCustomThemeDeleted: (String) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showCreateDialog by remember { mutableStateOf(false) } + + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp) + ) { + Text( + text = "App Theme", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text("Appearance", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) { + AppThemeMode.entries.forEach { mode -> + val isSelected = uiState.appThemeMode == mode + Box( + modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onThemeModeChanged(mode) }, + contentAlignment = Alignment.Center + ) { + Text(mode.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Text("Contrast", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) { + AppContrastOption.entries.forEach { option -> + val isSelected = uiState.appContrastOption == option + Box( + modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onContrastOptionChanged(option) }, + contentAlignment = Alignment.Center + ) { + Text(option.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Text("Text Brightness", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + androidx.compose.material3.Slider( + value = uiState.appTextDimFactor, + onValueChange = onTextDimFactorChanged, + valueRange = 0.3f..1.0f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) + } + + Spacer(Modifier.height(24.dp)) + + Text("Color Scheme", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + item { + ThemeSwatch( + color = MaterialTheme.colorScheme.primary, + isSelected = uiState.appSeedColor == null, + label = "Dynamic", + onClick = { onSeedColorChanged(null) } + ) + } + val presets = listOf( + "Ocean" to Color(0xFF00668B), + "Mint" to Color(0xFF006C4C), + "Rose" to Color(0xFF9C4146), + "Sepia" to Color(0xFF705D49), + "Amethyst" to Color(0xFF9B59B6), + "Amber" to Color(0xFFFFC107), + "Sapphire" to Color(0xFF0F52BA) + ) + items(presets.size) { i -> + val (label, color) = presets[i] + ThemeSwatch( + color = color, + isSelected = uiState.appSeedColor == color, + label = label, + onClick = { onSeedColorChanged(color) } + ) + } + } + + Spacer(Modifier.height(24.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Add, contentDescription = "Add Custom Theme", tint = MaterialTheme.colorScheme.primary) + } + } + Spacer(Modifier.height(8.dp)) + + if (uiState.customAppThemes.isEmpty()) { + Text("No custom themes yet.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(uiState.customAppThemes) { theme -> + ThemeSwatch( + color = theme.seedColor, + isSelected = uiState.appSeedColor == theme.seedColor, + label = theme.name, + onClick = { onSeedColorChanged(theme.seedColor) }, + onDelete = { onCustomThemeDeleted(theme.id) } + ) + } + } + } + } + } + + if (showCreateDialog) { + CreateAppThemeDialog( + onDismiss = { showCreateDialog = false }, + onSave = { name, color -> + onCustomThemeAdded(CustomAppTheme(id = System.currentTimeMillis().toString(), name = name, seedColor = color)) + showCreateDialog = false + } + ) + } +} + +@Composable +fun ThemeSwatch( + color: Color, + isSelected: Boolean, + label: String, + onClick: () -> Unit, + onDelete: (() -> Unit)? = null +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(56.dp) + .background(color, CircleShape) + .border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon(Icons.Default.Check, contentDescription = null, tint = if (color.luminance() > 0.5f) Color.Black else Color.White) + } + } + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 64.dp)) + if (onDelete != null) { + Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(16.dp).clickable { onDelete() }, tint = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +fun CreateAppThemeDialog( + initialColor: Color = Color(0xFF6750A4), + onDismiss: () -> Unit, + onSave: (String, Color) -> Unit +) { + var name by remember { mutableStateOf("") } + + val initialHsv = remember(initialColor) { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv) + hsv + } + + var hue by androidx.compose.runtime.mutableFloatStateOf(initialHsv[0]) + var saturation by androidx.compose.runtime.mutableFloatStateOf(initialHsv[1]) + var value by androidx.compose.runtime.mutableFloatStateOf(initialHsv[2]) + + val currentColor by remember { + androidx.compose.runtime.derivedStateOf { + val hsv = floatArrayOf(hue, saturation, value) + Color(android.graphics.Color.HSVToColor(255, hsv)) + } + } + + fun updateFromColor(color: Color) { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(color.toArgb(), hsv) + hue = hsv[0] + saturation = hsv[1] + value = hsv[2] + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp), + color = Color(0xFF2C2C2C), + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(androidx.compose.foundation.rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Create App Theme", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + + Spacer(Modifier.height(16.dp)) + + androidx.compose.material3.OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Theme Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = currentColor, + focusedLabelColor = currentColor, + ) + ) + + Spacer(Modifier.height(20.dp)) + + SpectrumBox( + hue = hue, + saturation = saturation, + currentColor = currentColor, + onHueSatChanged = { h, s -> hue = h; saturation = s }, + modifier = Modifier.fillMaxWidth().height(220.dp) + ) + + Spacer(Modifier.height(20.dp)) + + BrightnessSlider( + hue = hue, + saturation = saturation, + value = value, + onValueChanged = { value = it }, + modifier = Modifier.fillMaxWidth().height(24.dp).clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) + ) + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ColorComparePill( + oldColor = initialColor, + newColor = currentColor, + modifier = Modifier.width(64.dp).height(36.dp) + ) + + Column( + modifier = Modifier.weight(1.6f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("HEX", color = Color.Gray, fontSize = 12.sp, maxLines = 1) + Spacer(Modifier.height(4.dp)) + HexInput(color = currentColor, onHexChanged = { updateFromColor(it) }) + } + + Row( + modifier = Modifier.weight(2.4f), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + RgbInputColumn(label = "R", value = currentColor.red, + onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) }, + modifier = Modifier.weight(1f) + ) + RgbInputColumn(label = "G", value = currentColor.green, + onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) }, + modifier = Modifier.weight(1f) + ) + RgbInputColumn(label = "B", value = currentColor.blue, + onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) }, + modifier = Modifier.weight(1f) + ) + } + } + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("Cancel", color = Color.Gray) + } + Spacer(Modifier.width(8.dp)) + androidx.compose.material3.Button( + onClick = { onSave(name.ifBlank { "Custom Theme" }, currentColor) }, + colors = ButtonDefaults.buttonColors(containerColor = currentColor) + ) { + Text("Save", color = if (currentColor.luminance() > 0.5f) Color.Black else Color.White, fontWeight = FontWeight.Bold) + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index 488a1d4..e115f65 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -37,10 +37,13 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController -import com.aryan.reader.data.PlatformFeaturesRepository // Import the new repo +import com.aryan.reader.data.PlatformFeaturesRepository import com.aryan.reader.ui.theme.AppTheme import kotlinx.coroutines.launch import timber.log.Timber +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle class MainActivity : ComponentActivity() { @@ -78,7 +81,21 @@ class MainActivity : ComponentActivity() { } setContent { - AppTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val darkTheme = when (uiState.appThemeMode) { + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + AppThemeMode.SYSTEM -> isSystemInDarkTheme() + } + + AppTheme( + darkTheme = darkTheme, + dynamicColor = uiState.appSeedColor == null, + seedColor = uiState.appSeedColor, + contrastLevel = uiState.appContrastOption.value, + textDimFactor = uiState.appTextDimFactor + ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 0ca23dc..c06ab06 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -32,6 +32,7 @@ import android.net.Uri import android.os.Build import android.provider.DocumentsContract import android.provider.OpenableColumns +import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit import androidx.core.net.toUri import androidx.credentials.exceptions.GetCredentialCancellationException @@ -113,6 +114,7 @@ import java.util.UUID import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import androidx.core.graphics.createBitmap +import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.flow.distinctUntilChanged private const val KEY_RENDER_MODE = "render_mode" @@ -136,6 +138,24 @@ enum class AddBooksSource(val displayName: String) { UNSHELVED("Unshelved"), ALL_BOOKS("All Books") } +enum class AppThemeMode(val displayName: String) { + SYSTEM("System"), + LIGHT("Light"), + DARK("Dark") +} + +enum class AppContrastOption(val displayName: String, val value: Double) { + STANDARD("Standard", 0.0), + MEDIUM("Medium", 0.5), + HIGH("High", 1.0) +} + +data class CustomAppTheme( + val id: String, + val name: String, + val seedColor: androidx.compose.ui.graphics.Color +) + enum class FileType { PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT } @@ -245,6 +265,11 @@ data class ReaderScreenState( val showExternalFileSavePromptFor: String? = null, val externalFileBehavior: String = "ASK", val useStrictFileFilter: Boolean = false, + val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, + val appContrastOption: AppContrastOption = AppContrastOption.STANDARD, + val appTextDimFactor: Float = 1.0f, + val appSeedColor: androidx.compose.ui.graphics.Color? = null, + val customAppThemes: List = emptyList() ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -355,7 +380,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } ?: emptyList(), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", - useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false) + useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false), + appThemeMode = try { + AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name) + } catch (_: Exception) { AppThemeMode.SYSTEM }, + appContrastOption = try { + AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name) + } catch (_: Exception) { AppContrastOption.STANDARD }, + appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f), + appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null, + customAppThemes = loadCustomAppThemes(prefs) ) ) @@ -2631,6 +2665,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio title = displayName if (type == FileType.PDF) { + try { + val pdfiumCore = PdfiumCore(appContext) + appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val pdfDocument = pdfiumCore.newDocument(pfd) + val meta = pdfiumCore.getDocumentMeta(pdfDocument) + + val extractedTitle = meta.title + if (!extractedTitle.isNullOrBlank()) { + title = extractedTitle + } + + val extractedAuthor = meta.author + if (!extractedAuthor.isNullOrBlank()) { + author = extractedAuthor + } + + pdfiumCore.closeDocument(pdfDocument) + } + } catch (e: Exception) { + Timber.e(e, "Failed to extract PDF title using PdfiumCore") + } + val pdfCoverGenerator = PdfCoverGenerator(appContext) val coverBitmap = pdfCoverGenerator.generateCover(uri) if (coverBitmap != null) { @@ -4491,6 +4547,82 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(useStrictFileFilter = enabled) } } + private fun loadCustomAppThemes(prefs: SharedPreferences): List { + val jsonString = prefs.getString(KEY_CUSTOM_APP_THEMES, "[]") ?: "[]" + val themes = mutableListOf() + try { + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + themes.add( + CustomAppTheme( + id = obj.getString("id"), + name = obj.getString("name"), + seedColor = androidx.compose.ui.graphics.Color(obj.getInt("seedColor")) + ) + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse custom app themes") + } + return themes + } + + fun setAppThemeMode(mode: AppThemeMode) { + _internalState.update { it.copy(appThemeMode = mode) } + prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) } + } + + fun setAppContrastOption(option: AppContrastOption) { + _internalState.update { it.copy(appContrastOption = option) } + prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } + } + + fun setAppTextDimFactor(factor: Float) { + _internalState.update { it.copy(appTextDimFactor = factor) } + prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) } + } + + fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) { + _internalState.update { it.copy(appSeedColor = color) } + prefs.edit { + if (color == null) { + remove(KEY_APP_SEED_COLOR) + } else { + putInt(KEY_APP_SEED_COLOR, (color).toArgb()) + } + } + } + + fun addCustomAppTheme(theme: CustomAppTheme) { + val current = _internalState.value.customAppThemes.filter { it.id != theme.id } + theme + _internalState.update { it.copy(customAppThemes = current) } + saveCustomAppThemes(current) + setAppSeedColor(theme.seedColor) + } + + fun deleteCustomAppTheme(themeId: String) { + val current = _internalState.value.customAppThemes.filter { it.id != themeId } + _internalState.update { it.copy(customAppThemes = current) } + saveCustomAppThemes(current) + if (_internalState.value.appSeedColor != null && !current.any { it.seedColor == _internalState.value.appSeedColor }) { + setAppSeedColor(null) + } + } + + private fun saveCustomAppThemes(themes: List) { + val jsonArray = JSONArray() + themes.forEach { theme -> + val obj = JSONObject().apply { + put("id", theme.id) + put("name", theme.name) + put("seedColor", (theme.seedColor).toArgb()) + } + jsonArray.put(obj) + } + prefs.edit { putString(KEY_CUSTOM_APP_THEMES, jsonArray.toString()) } + } + suspend fun getAuthToken(): String? { return authRepository.getIdToken() } @@ -4518,6 +4650,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_ACTIVE_TAB = "active_tab_book_id" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" + private const val KEY_APP_THEME_MODE = "app_theme_mode" + private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" + private const val KEY_APP_SEED_COLOR = "app_seed_color" + private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor" + private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes" val SUPPORTED_MIME_TYPES = arrayOf( "application/pdf", "application/epub+zip", "application/x-mobipocket-ebook", diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index 8caee9c..b70baab 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -9,6 +9,7 @@ import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.MobiParser import com.aryan.reader.pdf.PdfCoverGenerator +import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -109,6 +110,28 @@ class MetadataExtractionWorker( coverPath = recentFilesRepository.saveCoverToCache(it, uri) } title = item.displayName + + try { + val pdfiumCore = PdfiumCore(appContext) + appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val pdfDocument = pdfiumCore.newDocument(pfd) + val meta = pdfiumCore.getDocumentMeta(pdfDocument) + + val extractedTitle = meta.title + if (!extractedTitle.isNullOrBlank()) { + title = extractedTitle + } + + val extractedAuthor = meta.author + if (!extractedAuthor.isNullOrBlank()) { + author = extractedAuthor + } + + pdfiumCore.closeDocument(pdfDocument) + } + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore") + } } FileType.ODT, FileType.FODT -> { val book = odtParser.createOdtBook( diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 10a582f..9d28ffa 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -126,20 +126,21 @@ class AutoScrollJsBridge( } } -@Suppress("unused") // function used by JavaScript +@Suppress("unused") class TtsJsBridge( private val scope: CoroutineScope, private val ttsStructuredTextHandler: suspend (String) -> Unit ) { @JavascriptInterface fun onStructuredTextExtracted(json: String) { - Timber.tag("TTS_LIST_DIAG").d("Bridge received JSON: $json") + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS Bridge received JSON. Length: ${json.length}") if (json.isNotBlank() && json != "[]") { - scope.launch { + scope.launch(kotlinx.coroutines.Dispatchers.Default) { ttsStructuredTextHandler(json) } } else { - scope.launch { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").w("JS Bridge received empty or blank JSON. This may trigger a chapter skip.") + scope.launch(kotlinx.coroutines.Dispatchers.Default) { ttsStructuredTextHandler("[]") } } @@ -301,6 +302,17 @@ class AiJsBridge( } } +@Suppress("unused") +class FootnoteJsBridge( + private val onFootnoteRequestCallback: (String) -> Unit +) { + @JavascriptInterface + fun onFootnoteRequested(htmlContent: String) { + Timber.tag("FootnoteDiag").d("Kotlin Bridge received footnote content. Length: ${htmlContent.length}") + onFootnoteRequestCallback(htmlContent) + } +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun ChapterWebView( @@ -350,6 +362,7 @@ fun ChapterWebView( onSearch: (String) -> Unit, onNoteRequested: (String?) -> Unit, onContentReadyForSummarization: suspend (String) -> Unit, + onFootnoteRequested: (String) -> Unit, currentFontFamily: ReaderFont, customFontPath: String? = null, currentTextAlign: ReaderTextAlign, @@ -537,6 +550,15 @@ fun ChapterWebView( consoleMessage?.let { val message = it.message() when { + message.startsWith("FootnoteDiag:") -> { + Timber.tag("FootnoteDiag") + .d("JS -> ${message.substringAfter("FootnoteDiag: ")}") + } + + message.startsWith("TTS_CHAPTER_CHANGE_DIAG:") -> { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS -> ${message.substringAfter("TTS_CHAPTER_CHANGE_DIAG: ")}") + } + message.startsWith("BookmarkDiagnosis") -> { Timber.tag("BookmarkDiagnosis") .d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") @@ -625,6 +647,12 @@ fun ChapterWebView( AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge" ) + addJavascriptInterface( + FootnoteJsBridge { html -> + this.post { onFootnoteRequested(html) } + }, "FootnoteBridge" + ) + webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index 0538491..094dfd7 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -20,6 +20,7 @@ package com.aryan.reader.epubreader import android.content.Context +import android.widget.TextView import timber.log.Timber import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -54,15 +55,19 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.edit +import androidx.core.text.HtmlCompat import com.aryan.reader.R import com.aryan.reader.epub.EpubChapter import org.json.JSONArray @@ -905,4 +910,87 @@ fun PaginatedTextSelectionMenu( } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FootnoteBottomSheet( + htmlContent: String, + effectiveBg: Color, + effectiveText: Color, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val maxSheetHeight = configuration.screenHeightDp.dp * 0.5f + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = effectiveBg, + contentColor = effectiveText, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.label_note), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + + Surface( + color = effectiveText.copy(alpha = 0.05f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, effectiveText.copy(alpha = 0.1f)), + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + AndroidView( + factory = { context -> + TextView(context).apply { + setTextColor(effectiveText.toArgb()) + textSize = 16f + setLineSpacing(0f, 1.4f) + + isVerticalScrollBarEnabled = false + movementMethod = null + } + }, + update = { textView -> + textView.text = HtmlCompat.fromHtml( + htmlContent, + HtmlCompat.FROM_HTML_MODE_COMPACT + ).trimEnd() + } + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt index 3cac01b..420c1d6 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt @@ -62,6 +62,7 @@ suspend fun loadChapterContent( val (headContent, chunks) = if (htmlFile.exists()) { val doc = Jsoup.parse(htmlFile, "UTF-8") val head = doc.head().html() + doc.select("script").remove() val bodyNodes = doc.body().childNodes().toList() val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes -> chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 625a174..f58519b 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -498,6 +498,7 @@ fun EpubReaderHost( var pendingNoteForNewHighlight by remember { mutableStateOf(false) } var highlightToNoteCfi by remember { mutableStateOf(null) } + var activeFootnoteHtml by remember { mutableStateOf(null) } var showJustifyWarningDialog by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) } @@ -509,6 +510,7 @@ fun EpubReaderHost( var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } + var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } var removeEdgePadding by remember { mutableStateOf(loadRemoveEdgePadding(context)) } @@ -809,7 +811,6 @@ fun EpubReaderHost( var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) } var userStoppedTts by remember { mutableStateOf(false) } - var skipChapterRequest by remember { mutableStateOf(false) } var ttsChapterIndex by remember { mutableStateOf(null) } var searchHighlightTarget by remember { mutableStateOf(null) } @@ -878,7 +879,7 @@ fun EpubReaderHost( var activeFragmentId by remember { mutableStateOf(null) } val density = LocalDensity.current - val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() } + val dragThresholdPx = with(density) { DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP.toPx() * pullToTurnMultiplier } var currentScrollYPosition by rememberSaveable(epubBook.title) { mutableIntStateOf(0) @@ -1000,20 +1001,6 @@ fun EpubReaderHost( } } - LaunchedEffect(skipChapterRequest) { - if (skipChapterRequest) { - skipChapterRequest = false - if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) { - Timber.d("Executing skip chapter request for continuous TTS.") - currentScrollYPosition = 0 - currentScrollHeightValue = 0 - currentChapterIndex++ - } else { - ttsShouldStartOnChapterLoad = false - } - } - } - val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) @@ -1135,7 +1122,7 @@ fun EpubReaderHost( } fun startTts() { - if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return } @@ -1193,7 +1180,7 @@ fun EpubReaderHost( ) fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { - if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true return } @@ -1275,17 +1262,23 @@ fun EpubReaderHost( ttsChapterIndex = ttsChapterIndex, onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex }, onNavigateToChapter = { 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 -> ttsShouldStartOnChapterLoad = shouldStart }, + onToggleTtsStartOnLoad = { shouldStart -> + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart") + ttsShouldStartOnChapterLoad = shouldStart + }, userStoppedTts = userStoppedTts, scope = scope, currentTtsMode = currentTtsMode, - getAuthToken = { viewModel.getAuthToken() } + getAuthToken = { viewModel.getAuthToken() }, + locatorConverter = locatorConverter, + epubBook = epubBook ) TtsHighlightHandler( @@ -2411,6 +2404,8 @@ fun EpubReaderHost( CircularProgressIndicator() } } else if (chapterChunks.isNotEmpty()) { + var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) } + val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { val targetIdx = loadUpToChunkIndex val startIdx = 0 @@ -2569,8 +2564,9 @@ fun EpubReaderHost( Timber.d("Auto-save enabled immediately.") } - if (ttsShouldStartOnChapterLoad) { + if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) { Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") + hasRequestedExtractionForThisChapter = true scope.launch { delay(200) webViewRefForTts?.evaluateJavascript( @@ -2773,7 +2769,7 @@ fun EpubReaderHost( onTtsTextReady = { jsonString -> scope.launch { val token = viewModel.getAuthToken() - Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this + Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") val ttsChunks = mutableListOf() try { val jsonArray = JSONArray(jsonString) @@ -2808,18 +2804,21 @@ fun EpubReaderHost( Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") if (ttsChunks.isNotEmpty()) { - if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true ttsShouldStartOnChapterLoad = false return@launch } ttsShouldStartOnChapterLoad = false - val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title + userStoppedTts = false + + val chapterTitle = chapters.getOrNull(targetChapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } - ttsChapterIndex = currentChapterIndex + ttsChapterIndex = targetChapterIndex + ttsController.start( chunks = ttsChunks, bookTitle = epubBook.title, @@ -2830,12 +2829,16 @@ fun EpubReaderHost( authToken = token ) } else { - Timber.w("No TTS chunks were created from JSON, not starting TTS." - ) + Timber.w("No TTS chunks were created from JSON, not starting TTS.") if (ttsShouldStartOnChapterLoad) { - Timber.d("Empty chapter detected during continuous TTS. Requesting skip." - ) - skipChapterRequest = true + Timber.d("Empty chapter detected during start. Advancing UI to next chapter.") + val nextIdx = targetChapterIndex + 1 + if (nextIdx < chapters.size) { + initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = nextIdx + } } } } @@ -2899,6 +2902,9 @@ fun EpubReaderHost( ) } }, + onFootnoteRequested = { html -> + activeFootnoteHtml = html + }, isProUser = isProUser, isOss = BuildConfig.FLAVOR == "oss", onShowDictionaryUpsellDialog = { @@ -3241,6 +3247,9 @@ fun EpubReaderHost( pendingNoteForNewHighlight = true } }, + onFootnoteRequested = { html -> + activeFootnoteHtml = html + }, onHighlightDeleted = { cfi -> val toRemove = userHighlights.find { it.cfi == cfi } if (toRemove != null) { @@ -4317,6 +4326,15 @@ fun EpubReaderHost( } } + if (activeFootnoteHtml != null) { + FootnoteBottomSheet( + htmlContent = activeFootnoteHtml!!, + effectiveBg = effectiveBg, + effectiveText = effectiveText, + onDismiss = { activeFootnoteHtml = null } + ) + } + CustomTopBanner(bannerMessage = bannerMessage) } } @@ -4452,6 +4470,11 @@ fun EpubReaderHost( removeEdgePadding = it saveRemoveEdgePadding(context, it) }, + pullToTurnMultiplier = pullToTurnMultiplier, + onPullToTurnMultiplierChange = { + pullToTurnMultiplier = it + savePullToTurnMultiplier(context, it) + }, onDismiss = { showVisualOptionsSheet = false } ) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index d3a79df..7ca4ecf 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -248,6 +248,18 @@ fun loadPullToTurn(context: Context): Boolean { return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true) } +private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier" + +fun savePullToTurnMultiplier(context: Context, multiplier: Float) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putFloat(PULL_TO_TURN_MULTIPLIER_KEY, multiplier) } +} + +fun loadPullToTurnMultiplier(context: Context): Float { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f) +} + fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -751,6 +763,8 @@ fun VisualOptionsSheet( onPullToTurnChange: (Boolean) -> Unit, removeEdgePadding: Boolean, onRemoveEdgePaddingChange: (Boolean) -> Unit, + pullToTurnMultiplier: Float, + onPullToTurnMultiplierChange: (Float) -> Unit, onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -807,21 +821,42 @@ fun VisualOptionsSheet( Surface( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier - .fillMaxWidth() - .clickable { onPullToTurnChange(!pullToTurnEnabled) } + modifier = Modifier.fillMaxWidth() ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPullToTurnChange(!pullToTurnEnabled) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) }) + } + + AnimatedVisibility(visible = pullToTurnEnabled) { + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)) + Text("Pull Distance to Change Chapter", style = MaterialTheme.typography.titleSmall) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Short", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Slider( + value = pullToTurnMultiplier, + onValueChange = onPullToTurnMultiplierChange, + valueRange = 0.5f..2.0f, + steps = 14, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp) + ) + Text("Long", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } } - Spacer(modifier = Modifier.width(16.dp)) - Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) }) } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index b8997b3..8540b61 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -28,9 +28,9 @@ import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.core.content.edit import androidx.media3.common.util.UnstableApi import com.aryan.reader.RenderMode @@ -101,11 +101,9 @@ fun TtsSessionObserver( chapters: List, epubBookTitle: String, coverImagePath: String?, - // Vertical Mode Dependencies webViewRef: WebView?, loadedChunkCount: Int, totalChunksInChapter: Int, - // Paginated Mode Dependencies paginator: IPaginator?, pagerState: PagerState, ttsChapterIndex: Int?, @@ -115,59 +113,94 @@ fun TtsSessionObserver( userStoppedTts: Boolean, scope: CoroutineScope, currentTtsMode: TtsMode, - getAuthToken: suspend () -> String? + getAuthToken: suspend () -> String?, + locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW + epubBook: com.aryan.reader.epub.EpubBook // NEW ) { - val prevTtsState = remember { mutableStateOf(ttsState) } + val currentRenderModeState = rememberUpdatedState(currentRenderMode) + val loadedChunkCountState = rememberUpdatedState(loadedChunkCount) + val totalChunksInChapterState = rememberUpdatedState(totalChunksInChapter) + val ttsChapterIndexState = rememberUpdatedState(ttsChapterIndex) + val userStoppedTtsState = rememberUpdatedState(userStoppedTts) + val chaptersState = rememberUpdatedState(chapters) + val webViewRefState = rememberUpdatedState(webViewRef) + val paginatorState = rememberUpdatedState(paginator) + val pagerStateState = rememberUpdatedState(pagerState) + val onToggleTtsStartOnLoadState = rememberUpdatedState(onToggleTtsStartOnLoad) + val onNavigateToChapterState = rememberUpdatedState(onNavigateToChapter) + val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange) + val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW + val epubBookState = rememberUpdatedState(epubBook) // NEW - LaunchedEffect(ttsState) { - val wasPlaying = prevTtsState.value.isPlaying - val isPlaying = ttsState.isPlaying - val sessionFinished = ttsState.sessionFinished - val wasSessionFinished = prevTtsState.value.sessionFinished - val sessionEndedByStop = ttsState.sessionEndedByStop - val isReaderSource = ttsState.playbackSource == "READER" + DisposableEffect(ttsController) { + val job = scope.launch { + var wasPlaying = false + var wasSessionFinished = false - if (isReaderSource) { - if (sessionFinished && !wasSessionFinished) { - Timber.d("TTS finished naturally. Checking for next content.") + ttsController.ttsState.collect { currentState -> + val isPlaying = currentState.isPlaying + val sessionFinished = currentState.sessionFinished + val sessionEndedByStop = currentState.sessionEndedByStop + val isReaderSource = currentState.playbackSource == "READER" - if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { - handleVerticalAutoAdvance( - webViewRef = webViewRef, - loadedChunkCount = loadedChunkCount, - totalChunksInChapter = totalChunksInChapter, - currentTtsChapterIndex = ttsChapterIndex, - totalChapters = chapters.size, - onNavigateToNextChapter = { nextIndex -> - onToggleTtsStartOnLoad(true) - onNavigateToChapter(nextIndex) - }, - onStopTts = { onTtsChapterIndexChange(null) } - ) - } else if (currentRenderMode == RenderMode.PAGINATED) { - handlePaginatedAutoAdvance( - ttsController = ttsController, - paginator = paginator, - pagerState = pagerState, - chapters = chapters, - currentTtsChapterIndex = ttsChapterIndex, - epubBookTitle = epubBookTitle, - coverImagePath = coverImagePath, - onUpdateTtsChapter = onTtsChapterIndexChange, - scope = scope, - ttsMode = currentTtsMode, - getAuthToken = getAuthToken - ) - } - } else if (wasPlaying && !isPlaying && !sessionFinished) { - // Playback stopped/paused - if (userStoppedTts || sessionEndedByStop) { - Timber.d("TTS stopped by user/stop command.") - onTtsChapterIndexChange(null) + if (isReaderSource) { + if (sessionFinished && !wasSessionFinished) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("TTS finished naturally. Checking for next content.") + + if (currentRenderModeState.value == RenderMode.VERTICAL_SCROLL) { + handleVerticalAutoAdvance( + webViewRef = webViewRefState.value, + loadedChunkCount = loadedChunkCountState.value, + totalChunksInChapter = totalChunksInChapterState.value, + currentTtsChapterIndex = ttsChapterIndexState.value, + totalChapters = chaptersState.value.size, + onNavigateToNextChapter = { nextIndex -> + onToggleTtsStartOnLoadState.value(false) + onNavigateToChapterState.value(nextIndex) + }, + onUpdateTtsChapter = onTtsChapterIndexChangeState.value, + onStopTts = { onTtsChapterIndexChangeState.value(null) }, + chapters = chaptersState.value, + currentTtsMode = currentTtsMode, + epubBookTitle = epubBookTitle, + coverImagePath = coverImagePath, + getAuthToken = getAuthToken, + ttsController = ttsController, + scope = this, + locatorConverter = locatorConverterState.value, + epubBook = epubBookState.value + ) + } else if (currentRenderModeState.value == RenderMode.PAGINATED) { + handlePaginatedAutoAdvance( + ttsController = ttsController, + paginator = paginatorState.value, + pagerState = pagerStateState.value, + chapters = chaptersState.value, + currentTtsChapterIndex = ttsChapterIndexState.value, + epubBookTitle = epubBookTitle, + coverImagePath = coverImagePath, + onUpdateTtsChapter = onTtsChapterIndexChangeState.value, + scope = this, + ttsMode = currentTtsMode, + getAuthToken = getAuthToken + ) + } + } else if (wasPlaying && !isPlaying && !sessionFinished) { + if (userStoppedTtsState.value || sessionEndedByStop) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TTS stopped by user/stop command.") + onTtsChapterIndexChangeState.value(null) + } + } } + + wasPlaying = isPlaying + wasSessionFinished = sessionFinished } } - prevTtsState.value = ttsState + + onDispose { + job.cancel() + } } } @@ -186,7 +219,6 @@ fun TtsHighlightHandler( ttsChapterIndex: Int?, scope: CoroutineScope ) { - // 1. Vertical & General Highlighting (WebView) LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) { val text = ttsState.currentText val cfi = ttsState.sourceCfi @@ -195,7 +227,6 @@ fun TtsHighlightHandler( if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) { val escapedText = escapeJsString(text) val escapedCfi = escapeJsString(cfi) - // Use window.highlightFromCfi defined in epub_reader.js val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);" webViewRef?.evaluateJavascript(jsCommand, null) } else { @@ -205,7 +236,6 @@ fun TtsHighlightHandler( } } - // 2. Paginated Page Turning (Sentence/Fragment level) LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) { if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect @@ -218,7 +248,7 @@ fun TtsHighlightHandler( if (targetPage != null && targetPage != pagerState.currentPage) { scope.launch { - pagerState.animateScrollToPage(targetPage) + pagerState.scrollToPage(targetPage) } } } @@ -226,6 +256,7 @@ fun TtsHighlightHandler( // --- Internal Helper Functions --- +@OptIn(UnstableApi::class) private fun handleVerticalAutoAdvance( webViewRef: WebView?, loadedChunkCount: Int, @@ -233,20 +264,88 @@ private fun handleVerticalAutoAdvance( currentTtsChapterIndex: Int?, totalChapters: Int, onNavigateToNextChapter: (Int) -> Unit, - onStopTts: () -> Unit + onUpdateTtsChapter: (Int?) -> Unit, + onStopTts: () -> Unit, + chapters: List, + currentTtsMode: TtsMode, + epubBookTitle: String, + coverImagePath: String?, + getAuthToken: suspend () -> String?, + ttsController: TtsController, + scope: CoroutineScope, + locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, + epubBook: com.aryan.reader.epub.EpubBook ) { - if (loadedChunkCount < totalChunksInChapter) { - Timber.d("Vertical: Loading next chunk for TTS.") - webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null) - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) - }, 500) - } else { - if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) { - Timber.d("Vertical: Chapter finished, moving to next.") - onNavigateToNextChapter(currentTtsChapterIndex + 1) - } else { - Timber.d("Vertical: End of book.") + if (currentTtsChapterIndex == null) return + + scope.launch { + val currentState = ttsController.ttsState.value + val lastReadCfi = currentState.sourceCfi + + if (loadedChunkCount < totalChunksInChapter) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.") + val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) + + if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) { + val lastCfiPath = lastReadCfi.split(":")[0] + val resumeIdx = nativeChunks.indexOfLast { it.sourceCfi.split(":")[0] == lastCfiPath } + + if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) { + val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size) + val token = getAuthToken() + ttsController.start( + chunks = remainingChunks, + bookTitle = epubBookTitle, + chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, + coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, + ttsMode = currentTtsMode, + playbackSource = "READER", + authToken = token + ) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + webViewRef?.evaluateJavascript("javascript:if(window.virtualization && window.virtualization.loadNextChunk) window.virtualization.loadNextChunk();", null) + } + return@launch + } + } + } + + var nextIdx = currentTtsChapterIndex + 1 + var foundContent = false + + while (nextIdx < totalChapters) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Trying chapter $nextIdx natively.") + val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, nextIdx) + + if (!nativeChunks.isNullOrEmpty()) { + val token = getAuthToken() + + onUpdateTtsChapter(nextIdx) + + ttsController.start( + chunks = nativeChunks, + bookTitle = epubBookTitle, + chapterTitle = chapters.getOrNull(nextIdx)?.title, + coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, + ttsMode = currentTtsMode, + playbackSource = "READER", + authToken = token + ) + + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + onNavigateToNextChapter(nextIdx) + } + + foundContent = true + break + } else { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Chapter $nextIdx is empty natively. Skipping to next.") + nextIdx++ + } + } + + if (!foundContent) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Reached end of book or no more valid content.") onStopTts() } } @@ -268,7 +367,7 @@ private fun handlePaginatedAutoAdvance( getAuthToken: suspend () -> String? ) { if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { - Timber.d("Paginated: Searching for next TTS content...") + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...") scope.launch { var chapterToTry = currentTtsChapterIndex + 1 @@ -283,19 +382,18 @@ private fun handlePaginatedAutoAdvance( while (chapterToTry < chapters.size) { val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry] if (targetPage != null && pagerState.currentPage != targetPage) { - pagerState.animateScrollToPage(targetPage) - delay(300) + // CHANGED: Fire-and-forget scroll without frame blocking! + launch { pagerState.scrollToPage(targetPage) } } val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry) if (!nextChapterChunks.isNullOrEmpty()) { - Timber.d("Paginated: Found content in chapter $chapterToTry. Starting.") + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Found content in chapter $chapterToTry. Starting.") onUpdateTtsChapter(chapterToTry) val chapterTitle = chapters.getOrNull(chapterToTry)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } - val token = getAuthToken() ttsController.start( @@ -310,20 +408,13 @@ private fun handlePaginatedAutoAdvance( foundContent = true break } else { - Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.") - val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0 - if (pageCount > 1) { - for (i in 1 until pageCount) { - pagerState.animateScrollToPage(targetPage!! + i) - delay(400) - } - } + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Chapter $chapterToTry is empty. Skipping.") chapterToTry++ } } if (!foundContent) { - Timber.d("Paginated: No more content found.") + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: No more content found.") onUpdateTtsChapter(null) } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt index b8fd8d4..2fc9858 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/InteractiveWebView.kt @@ -69,6 +69,15 @@ class InteractiveWebView( override fun onSingleTapConfirmed(e: MotionEvent): Boolean { Timber.d("onSingleTapConfirmed") + + val hitTestResult = this@InteractiveWebView.hitTestResult + val type = hitTestResult.type + + if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + Timber.d("Tap was on a link. Consuming tap, not toggling app bars.") + return true + } + onSingleTap() return true } @@ -117,12 +126,16 @@ class InteractiveWebView( } if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) { - Timber.d("Drag operation started ($currentDragOperation), disabling text selection." - ) + Timber.d("Drag operation started ($currentDragOperation), disabling text selection.") evaluateJavascript( "javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);", null ) + + val cancelEvent = MotionEvent.obtain(event) + cancelEvent.action = MotionEvent.ACTION_CANCEL + super.onTouchEvent(cancelEvent) + cancelEvent.recycle() } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index f2b5cb6..6b1e545 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -224,6 +224,61 @@ class LocatorConverter( return bestMatch } + suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List? = withContext(Dispatchers.IO) { + val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex) + + var allBlocks: List? = null + if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { + allBlocks = try { + proto.decodeFromByteArray>(processedChapter.contentBlocksProto) + } catch (_: Exception) { null } + } + + if (allBlocks.isNullOrEmpty()) { + allBlocks = processAndCacheChapter(book, chapterIndex) + } + + if (allBlocks.isNullOrEmpty()) return@withContext null + + val chunks = mutableListOf() + + fun traverse(blocks: List) { + for (block in blocks) { + if (block is SemanticTextBlock && block.cfi != null && block.text.isNotBlank()) { + val subChunks = com.aryan.reader.tts.splitTextIntoChunks(block.text) + var currentSearchIndex = 0 + for (chunkText in subChunks) { + val firstWord = chunkText.trim().substringBefore(' ') + val relativeOffset = if (firstWord.isNotEmpty()) { + val idx = block.text.indexOf(firstWord, currentSearchIndex) + if (idx != -1) idx else currentSearchIndex + } else { + currentSearchIndex + } + chunks.add( + TtsChunk( + text = chunkText, + sourceCfi = block.cfi!!, + startOffsetInSource = block.startCharOffsetInSource + relativeOffset + ) + ) + currentSearchIndex = relativeOffset + chunkText.length + } + } + when (block) { + is SemanticFlexContainer -> traverse(block.children) + is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> traverse(cell.content) } } + is SemanticList -> traverse(block.items) + is SemanticWrappingBlock -> traverse(block.paragraphsToWrap) + else -> Unit + } + } + } + + traverse(allBlocks) + chunks + } + suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index fd947f1..d5f7fd2 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -55,9 +55,11 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -65,8 +67,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.BlendMode @@ -74,10 +79,16 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath @@ -127,6 +138,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.toSize import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import androidx.core.net.toUri import coil.ImageLoader @@ -144,6 +156,7 @@ import com.aryan.reader.epubreader.ReaderTextAlign import com.aryan.reader.epubreader.TtsHighlightInfo import com.aryan.reader.epubreader.UserHighlight import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -151,12 +164,17 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf +import org.jsoup.Jsoup import timber.log.Timber import java.io.File +import java.net.URI +import java.net.URLDecoder import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.math.sqrt data class PaginatedSelection( val startBlockIndex: Int, @@ -173,7 +191,7 @@ data class PaginatedSelection( ) class ReactiveBlockMap( - private val delegate: MutableMap> = androidx.compose.runtime.mutableStateMapOf() + private val delegate: MutableMap> = mutableStateMapOf() ) : MutableMap> by delegate { var tick by mutableIntStateOf(0) @@ -522,6 +540,7 @@ fun PaginatedReaderScreen( onSearch: (String) -> Unit, onStartTtsFromSelection: (String, Int) -> Unit, onNoteRequested: (String?) -> Unit, + onFootnoteRequested: (String) -> Unit, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, @@ -540,15 +559,15 @@ fun PaginatedReaderScreen( val textureBitmap = remember(activeTextureId) { activeTextureId?.let { id -> ReaderTexture.entries.find { it.id == id }?.resId?.let { resId -> - androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId) + ImageBitmap.imageResource(context.resources, resId) } } } val textureModifier = if (textureBitmap != null) { Modifier.drawBehind { - val brush = androidx.compose.ui.graphics.ShaderBrush( - androidx.compose.ui.graphics.ImageShader(textureBitmap, androidx.compose.ui.graphics.TileMode.Repeated, androidx.compose.ui.graphics.TileMode.Repeated) + val brush = ShaderBrush( + ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated) ) drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f) } @@ -849,7 +868,8 @@ fun PaginatedReaderScreen( val result = paginator.getPageContent(pageIndex) val duration = System.currentTimeMillis() - startTime if (duration > 16) { - Timber.tag("PageTurnDiag").w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}") + Timber.tag("PageTurnDiag") + .w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}") } result }, @@ -866,7 +886,96 @@ fun PaginatedReaderScreen( } }, onLinkClick = { currentChapterPath, href, onNavComplete -> - paginator.navigateToHref(currentChapterPath, href, onNavComplete) + coroutineScope.launch(Dispatchers.IO) { + var isFootnote = false + var footnoteHtml: String? = null + + val sourceChapter = + book.chaptersForPagination.find { it.absPath == currentChapterPath } + if (sourceChapter != null) { + val sourceHtml = sourceChapter.htmlContent.ifEmpty { + try { + File(book.extractionBasePath, sourceChapter.htmlFilePath) + .readText() + } catch (_: Exception) { + "" + } + } + if (sourceHtml.isNotEmpty()) { + val doc = Jsoup.parse(sourceHtml) + val safeHref = href.replace("\"", "\\\"") + val aTag = doc.select("a[href=\"$safeHref\"]").first() + + if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) { + isFootnote = true + } + } else if (href.startsWith("#")) { + isFootnote = true + } + } else if (href.startsWith("#")) { + isFootnote = true + } + + if (isFootnote) { + val decodedHref = try { + URLDecoder.decode(href, "UTF-8") + } catch (_: Exception) { + href + } + val parts = decodedHref.split('#', limit = 2) + val pathPart = parts[0] + val anchor = if (parts.size > 1) parts[1] else null + + if (anchor != null) { + val targetPath = if (pathPart.isBlank()) currentChapterPath else { + try { + URI(currentChapterPath).resolve(pathPart) + .normalize().path + } catch (_: Exception) { + null + } + } + + if (targetPath != null) { + val targetChapter = book.chaptersForPagination.find { + try { + URI(it.absPath).normalize().path == targetPath + } catch (_: Exception) { + false + } + } + + if (targetChapter != null) { + val targetHtml = targetChapter.htmlContent.ifEmpty { + try { + File( + book.extractionBasePath, + targetChapter.htmlFilePath + ).readText() + } catch (_: Exception) { + "" + } + } + if (targetHtml.isNotEmpty()) { + val doc = Jsoup.parse(targetHtml) + val noteEl = doc.getElementById(anchor) + if (noteEl != null) { + footnoteHtml = noteEl.html() + } + } + } + } + } + } + + withContext(Dispatchers.Main) { + if (!footnoteHtml.isNullOrBlank()) { + onFootnoteRequested(footnoteHtml) + } else { + paginator.navigateToHref(currentChapterPath, href, onNavComplete) + } + } + } }, onTap = onTap, isProUser = isProUser, @@ -1772,7 +1881,7 @@ internal fun PaginatedReaderContent( if (uiState.totalPageCount > 0) { uiState.generation - var rootCoords by androidx.compose.runtime.remember { mutableStateOf(null) } + var rootCoords by remember { mutableStateOf(null) } var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) } val magnifierModifier = if (magnifierCenter.isSpecified) { @@ -3108,7 +3217,7 @@ internal fun PaginatedReaderContent( density ) { SmartPopupPositionProvider(menuAnchorRect, density) }, onDismissRequest = { activeSelection = null }, - properties = androidx.compose.ui.window.PopupProperties( + properties = PopupProperties( dismissOnClickOutside = false ) ) { @@ -3354,7 +3463,7 @@ internal fun PaginatedReaderContent( activeDragHandle } - val latestUpdateSelection by androidx.compose.runtime.rememberUpdatedState(updateSelection) + val latestUpdateSelection by rememberUpdatedState(updateSelection) listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType -> val isStart = handleType == SelectionHandle.START @@ -3455,7 +3564,7 @@ internal fun PaginatedReaderContent( contentDescription = if (isStart) "Start handle" else "End handle", modifier = Modifier.size(36.dp).graphicsLayer { rotationZ = if (isStart) 30f else -30f - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) + transformOrigin = TransformOrigin(0.5f, 0f) }, tint = Color(0xFF1976D2) ) @@ -3867,7 +3976,7 @@ private fun Modifier.realisticBookPage( if (pageOffset != 0f) { shadowElevation = 10f - shape = androidx.compose.ui.graphics.RectangleShape + shape = RectangleShape clip = false } } @@ -3897,7 +4006,7 @@ private fun Modifier.realisticBookPage( val dx = w - dragX val dy = cornerY - dragY - val nLen = kotlin.math.sqrt(dx * dx + dy * dy) + val nLen = sqrt(dx * dx + dy * dy) // CRITICAL GEOMETRY LOG if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens @@ -4035,12 +4144,12 @@ fun Modifier.drawCssBorders( if (blockStyle.backgroundColor.isSpecified && blockStyle.backgroundColor != Color.Transparent) { val bgPath = Path().apply { addRoundRect( - androidx.compose.ui.geometry.RoundRect( + RoundRect( rect = size.toRect(), - topLeft = androidx.compose.ui.geometry.CornerRadius(tlRadius, tlRadius), - topRight = androidx.compose.ui.geometry.CornerRadius(trRadius, trRadius), - bottomRight = androidx.compose.ui.geometry.CornerRadius(brRadius, brRadius), - bottomLeft = androidx.compose.ui.geometry.CornerRadius(blRadius, blRadius) + topLeft = CornerRadius(tlRadius, tlRadius), + topRight = CornerRadius(trRadius, trRadius), + bottomRight = CornerRadius(brRadius, brRadius), + bottomLeft = CornerRadius(blRadius, blRadius) ) ) } @@ -4134,7 +4243,7 @@ fun Modifier.drawCssBorders( startAngle = 180f, sweepAngle = 90f, useCenter = false, topLeft = Offset(leftWidth/2f, topWidth/2f), - size = androidx.compose.ui.geometry.Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth), + size = Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth), style = Stroke(width = topWidth) ) } @@ -4145,7 +4254,7 @@ fun Modifier.drawCssBorders( startAngle = 270f, sweepAngle = 90f, useCenter = false, topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f), - size = androidx.compose.ui.geometry.Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth), + size = Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth), style = Stroke(width = topWidth) ) } @@ -4156,7 +4265,7 @@ fun Modifier.drawCssBorders( startAngle = 0f, sweepAngle = 90f, useCenter = false, topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)), - size = androidx.compose.ui.geometry.Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth), + size = Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth), style = Stroke(width = bottomWidth) ) } @@ -4167,7 +4276,7 @@ fun Modifier.drawCssBorders( startAngle = 90f, sweepAngle = 90f, useCenter = false, topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)), - size = androidx.compose.ui.geometry.Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth), + size = Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth), style = Stroke(width = bottomWidth) ) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index c77d926..c182c6b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -34,6 +34,10 @@ import androidx.compose.material3.Switch import androidx.compose.material.icons.filled.Settings import androidx.compose.foundation.rememberScrollState import android.graphics.Bitmap +import com.aryan.reader.epubreader.SystemUiMode +import com.aryan.reader.epubreader.loadSystemUiMode +import com.aryan.reader.epubreader.saveSystemUiMode +import com.aryan.reader.epubreader.OptionSegmentedControl import android.graphics.RectF import android.net.Uri import android.os.Build @@ -127,8 +131,6 @@ import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -384,6 +386,7 @@ enum class PdfReaderTool(val title: String, val category: String) { DICTIONARY("External Apps", "Top Bar"), THEME("Theme Settings", "Top Bar"), LOCK_PANNING("Lock Panning", "Top Bar"), + VISUAL_OPTIONS("Visual Options", "Overflow Menu"), FULL_SCREEN("Full Screen", "Top Bar"), SLIDER("Navigation Slider", "Bottom Bar"), TOC("Sidebar", "Bottom Bar"), @@ -520,16 +523,6 @@ private fun loadPdfScrollLocked(context: Context, bookId: String): Boolean { return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false) } -private fun savePdfFullScreen(context: Context, bookId: String, isFull: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putBoolean(PDF_FULL_SCREEN_PREFIX + bookId, isFull) } -} - -private fun loadPdfFullScreen(context: Context, bookId: String): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(PDF_FULL_SCREEN_PREFIX + bookId, false) -} - private fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) } @@ -1238,6 +1231,8 @@ fun PdfViewerScreen( val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse" var pageAspectRatios by remember { mutableStateOf>(emptyList()) } var showBars by rememberSaveable { mutableStateOf(true) } + var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } + var showVisualOptionsSheet by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) } var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } @@ -1311,7 +1306,6 @@ fun PdfViewerScreen( LaunchedEffect(bookId) { isScrollLocked = loadPdfScrollLocked(context, bookId) - isFullScreen = loadPdfFullScreen(context, bookId) lockedState = loadPdfLockedState(context, bookId) } @@ -1529,42 +1523,84 @@ fun PdfViewerScreen( } val window = (view.context as? Activity)?.window - LaunchedEffect(isFullScreen) { + LaunchedEffect(systemUiMode, showBars) { if (window != null) { val insetsController = WindowCompat.getInsetsController(window, view) - if (isFullScreen) { - showBars = false - insetsController.hide(WindowInsetsCompat.Type.systemBars()) - insetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } else { - showBars = true - insetsController.show(WindowInsetsCompat.Type.systemBars()) + when (systemUiMode) { + SystemUiMode.DEFAULT -> { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + SystemUiMode.SYNC -> { + if (showBars) { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } else { + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + SystemUiMode.HIDDEN -> { + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } } } } val dockHeight = 64.dp val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() } + val density = LocalDensity.current - val verticalHeaderHeight by remember( + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val dummySearcher: suspend (String) -> List = { emptyList() } + val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher) + + val showStandardBars = showBars && !isEditMode + val snackbarPadding by animateDpAsState( + targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp, + label = "SnackbarPadding" + ) + + val targetVerticalHeaderHeight = remember( dockLocation, snapPreviewLocation, isEditMode, - isDockDragging + isDockDragging, + showStandardBars, + systemUiMode, + showBars, + statusBarHeightDp ) { - derivedStateOf { - if (!isEditMode) { - 0.dp - } else { - val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging - val isPreviewingTop = snapPreviewLocation == DockLocation.TOP - - if (isStickyTop || isPreviewingTop) dockHeight else 0.dp + if (!isEditMode) { + var h = 0.dp + if (showStandardBars) { + h += 56.dp } + + val isStatusBarVisible = when (systemUiMode) { + SystemUiMode.DEFAULT -> true + SystemUiMode.SYNC -> showBars + SystemUiMode.HIDDEN -> false + } + + if (isStatusBarVisible) { + h += statusBarHeightDp + } + h + } else { + val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging + val isPreviewingTop = snapPreviewLocation == DockLocation.TOP + if (isStickyTop || isPreviewingTop) dockHeight else 0.dp } } + val verticalHeaderHeight by animateDpAsState( + targetValue = targetVerticalHeaderHeight, + animationSpec = tween(durationMillis = 200), + label = "verticalHeaderHeight" + ) + val verticalFooterHeight by remember( dockLocation, snapPreviewLocation, @@ -2266,7 +2302,7 @@ fun PdfViewerScreen( } selectedTextBoxId = null } else { - if (!isFullScreen && !(isMusicianMode && isAutoScrollModeActive)) { + if (!(isMusicianMode && isAutoScrollModeActive)) { showBars = !showBars Timber.d("Vertical Reader Clicked. showBars now: $showBars") } @@ -3702,14 +3738,9 @@ fun PdfViewerScreen( var activeQuery by remember { mutableStateOf("") } - val dummySearcher: suspend (String) -> List = { emptyList() } - - val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher) - var smartSearchResult by remember { mutableStateOf(null) } var currentPdfSearchResult by remember { mutableStateOf(null) } - val density = LocalDensity.current val navBarHeight = WindowInsets.systemBars.getBottom(density) val imeHeight = WindowInsets.ime.getBottom(density) @@ -3733,16 +3764,7 @@ fun PdfViewerScreen( } } - val statusBarHeight = WindowInsets.statusBars.getTop(density) - val topBarHeightPx = with(density) { 56.dp.toPx() } - - val topScrollLimitPx = remember(showBars, statusBarHeight) { - if (showBars) { - statusBarHeight + topBarHeightPx - } else { - 0f - } - } + val topScrollLimitPx = with(density) { verticalHeaderHeight.toPx() } LaunchedEffect(searchState.searchQuery, currentBookId) { val query = searchState.searchQuery @@ -3902,10 +3924,7 @@ fun PdfViewerScreen( onNavigateBack() } - isFullScreen -> { - isFullScreen = false - savePdfFullScreen(context, bookId, false) - } + showVisualOptionsSheet -> showVisualOptionsSheet = false showReindexDialog != null -> showReindexDialog = null @@ -3950,12 +3969,6 @@ fun PdfViewerScreen( } } - val showStandardBars = showBars && !isEditMode - val snackbarPadding by animateDpAsState( - targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp, - label = "SnackbarPadding" - ) - ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { @@ -4487,10 +4500,8 @@ fun PdfViewerScreen( ) } ) { paddingValues -> - BoxWithConstraints(modifier = Modifier - .fillMaxSize() - .padding(paddingValues)) { - IntSize(constraints.maxWidth, constraints.maxHeight) + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())) { + IntSize(constraints.maxWidth, constraints.maxHeight) val boxConstraints = constraints val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat() val boxMaxHeightFloat = boxConstraints.maxHeight.toFloat() @@ -5755,22 +5766,6 @@ fun PdfViewerScreen( } } - if (!hiddenTools.contains(PdfReaderTool.FULL_SCREEN.name)) { - TooltipIconButton( - text = stringResource(R.string.tooltip_fullscreen), - description = stringResource(R.string.tooltip_fullscreen_desc), - onClick = { - isFullScreen = true - savePdfFullScreen(context, bookId, true) - }) { - Icon( - imageVector = Icons.Default.Fullscreen, - contentDescription = "Enter Full Screen", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) { TooltipIconButton( text = stringResource(R.string.tooltip_dictionary), @@ -5877,6 +5872,24 @@ fun PdfViewerScreen( HorizontalDivider() } + if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_visual_options)) }, + onClick = { + showMoreMenu = false + showVisualOptionsSheet = true + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) { DropdownMenuItem( text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, @@ -6612,42 +6625,6 @@ fun PdfViewerScreen( } } - // Full Screen Exit Button - AnimatedVisibility( - visible = isFullScreen, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(24.dp) - ) { - val isBackgroundDark = displayMode == DisplayMode.PAGINATION || isPdfDarkMode - - val fabContainerColor = if (isBackgroundDark) Color.White.copy(alpha = 0.25f) - else Color.Black.copy(alpha = 0.25f) - val fabContentColor = if (isBackgroundDark) Color.White else Color.Black - - Surface( - onClick = { - isFullScreen = false - savePdfFullScreen(context, bookId, false) - }, - color = fabContainerColor, - contentColor = fabContentColor, - shape = CircleShape, - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = "Exit Full Screen", - modifier = Modifier.size(26.dp) - ) - } - } - } - if (isEditMode) { val density = LocalDensity.current @@ -7994,6 +7971,16 @@ fun PdfViewerScreen( } ) } + if (showVisualOptionsSheet) { + PdfVisualOptionsSheet( + systemUiMode = systemUiMode, + onSystemUiModeChange = { mode -> + systemUiMode = mode + saveSystemUiMode(context, mode) + }, + onDismiss = { showVisualOptionsSheet = false } + ) + } if (showCustomizeToolsSheet) { PdfCustomizeToolsSheet( hiddenTools = hiddenTools, @@ -8715,4 +8702,50 @@ fun PdfCustomizeToolsSheet( } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PdfVisualOptionsSheet( + systemUiMode: SystemUiMode, + onSystemUiModeChange: (SystemUiMode) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + + Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(12.dp)) + + OptionSegmentedControl( + options = SystemUiMode.entries, + selectedOption = systemUiMode, + onOptionSelected = onSystemUiModeChange, + getLabel = { it.title } + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 09e1cd5..3d1bbf0 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -596,7 +596,9 @@ class TtsPlaybackManager( val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1 if (player.playbackState == Player.STATE_ENDED) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}") if (isLastChunkInSession || textChunks.isEmpty()) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true") nextState = nextState.copy(sessionFinished = true) } else { val nextIdx = currentChunkIndex + 1 diff --git a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt index b9c577c..771bed4 100644 --- a/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/aryan/reader/ui/theme/Theme.kt @@ -19,9 +19,7 @@ */ package com.aryan.reader.ui.theme -import android.annotation.SuppressLint import android.os.Build -import androidx.annotation.RequiresApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme @@ -29,7 +27,10 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.materialkolor.PaletteStyle import androidx.compose.ui.platform.LocalContext +import com.materialkolor.dynamicColorScheme private val lightScheme = lightColorScheme( primary = primaryLight, @@ -111,11 +112,21 @@ private val darkScheme = darkColorScheme( fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + seedColor: Color? = null, + contrastLevel: Double = 0.0, + textDimFactor: Float = 1.0f, content: @Composable () -> Unit ) { val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { + seedColor != null -> dynamicColorScheme( + seedColor = seedColor, + isDark = darkTheme, + contrastLevel = contrastLevel, + style = PaletteStyle.Fidelity + ) + dynamicColor && supportsDynamicColor -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -124,8 +135,22 @@ fun AppTheme( else -> lightScheme } + val finalColorScheme = colorScheme.copy( + onPrimary = colorScheme.onPrimary.copy(alpha = textDimFactor), + onSecondary = colorScheme.onSecondary.copy(alpha = textDimFactor), + onTertiary = colorScheme.onTertiary.copy(alpha = textDimFactor), + onBackground = colorScheme.onBackground.copy(alpha = textDimFactor), + onSurface = colorScheme.onSurface.copy(alpha = textDimFactor), + onSurfaceVariant = colorScheme.onSurfaceVariant.copy(alpha = textDimFactor), + onError = colorScheme.onError.copy(alpha = textDimFactor), + onPrimaryContainer = colorScheme.onPrimaryContainer.copy(alpha = textDimFactor), + onSecondaryContainer = colorScheme.onSecondaryContainer.copy(alpha = textDimFactor), + onTertiaryContainer = colorScheme.onTertiaryContainer.copy(alpha = textDimFactor), + onErrorContainer = colorScheme.onErrorContainer.copy(alpha = textDimFactor) + ) + MaterialTheme( - colorScheme = colorScheme, + colorScheme = finalColorScheme, typography = AppTypography, content = content )