App themes (#217)

* Updated `.gitattributes` to correctly vendor all files within the `libmobi` and `woff2` directories.

* Improved TTS chapter transition handling

* Improved TTS stability and chapter advancement in the EPUB reader.

* Implemented PDF metadata extraction for titles and authors using PdfiumCore in `MainViewModel` and `MetadataExtractionWorker`.

* Implemented support for interactive footnotes in the EPUB vertical reader.

* Added support for footnotes in the paginated EPUB reader.

* Implemented a customizable app theming system.

* Added support for app-wide contrast and text brightness customization in the "App Theme" option.

* option to adjust pull-to-chapter-change drag in the EPUB vertical reader under visual options.

* Updated `PdfViewerScreen` to replace the standalone full-screen toggle with a more comprehensive "Visual Options" system UI management tool.
This commit is contained in:
Aryan 2026-04-21 18:11:55 +05:30 committed by GitHub
parent 4cb25e6abd
commit 71e3614ad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1413 additions and 287 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
app/src/main/cpp/libmobi/** linguist-vendored
app/src/main/cpp/woff2/** linguist-vendored

View file

@ -225,6 +225,8 @@ dependencies {
implementation("io.legere:pdfiumandroid:2.0.0") implementation("io.legere:pdfiumandroid:2.0.0")
implementation("org.zwobble.mammoth:mammoth:1.4.2") implementation("org.zwobble.mammoth:mammoth:1.4.2")
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
} }
spotless { spotless {

View file

@ -442,14 +442,70 @@
return false; 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) // 1. Handle Taps (Click)
document.addEventListener( document.addEventListener("click", function (e) {
"click", if (handleHighlightInteraction(e)) return;
function (e) {
handleHighlightInteraction(e); var target = e.target;
}, var anchor = target.closest('a');
true
); 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) { window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) {
var logTag = "ReaderFontDiagnosis"; var logTag = "ReaderFontDiagnosis";
@ -1094,13 +1150,14 @@
}; };
window.extractTextWithCfiFromTop = function () { window.extractTextWithCfiFromTop = function () {
console.log("TTS_CHAPTER_CHANGE_DIAG: Starting extractTextWithCfiFromTop");
try { try {
const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote"; const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote";
const allContentNodesRaw = Array.from(document.body.querySelectorAll(ttsNodeSelector)); 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); const allContentNodes = allContentNodesRaw.filter(node => node.querySelector(ttsNodeSelector) === null);
console.log("TTS_CHAPTER_CHANGE_DIAG: Total nodes found: " + allContentNodes.length);
let startBlock = null; let startBlock = null;
let startIndex = -1; let startIndex = -1;
@ -1116,9 +1173,12 @@
} }
if (!startBlock) { if (!startBlock) {
console.log("TTS_CHAPTER_CHANGE_DIAG: No visible start block found in viewport.");
return window.extractTextWithCfi(); return window.extractTextWithCfi();
} }
console.log("TTS_CHAPTER_CHANGE_DIAG: Starting extraction from node index: " + startIndex);
const nodesToProcess = allContentNodes.slice(startIndex); const nodesToProcess = allContentNodes.slice(startIndex);
const results =[]; const results =[];
@ -1136,6 +1196,7 @@
return JSON.stringify(results); return JSON.stringify(results);
} catch (e) { } catch (e) {
console.log("TTS_CHAPTER_CHANGE_DIAG: Error in extractTextWithCfiFromTop: " + e.message);
return window.extractTextWithCfi(); return window.extractTextWithCfi();
} }
}; };
@ -1240,13 +1301,20 @@
window.TtsBridgeHelper = { window.TtsBridgeHelper = {
extractAndRelayText: function () { extractAndRelayText: function () {
const traceId = Date.now();
console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] extractAndRelayText invoked.");
try { try {
const structuredTextJson = window.extractTextWithCfiFromTop(); 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) { if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
TtsBridge.onStructuredTextExtracted(structuredTextJson); TtsBridge.onStructuredTextExtracted(structuredTextJson);
} else {
console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] Bridge missing!");
} }
} catch (e) { } catch (e) {
console.log("TTS_CHAPTER_CHANGE_DIAG: [" + traceId + "] Error: " + e.message);
if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) { if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
TtsBridge.onStructuredTextExtracted("[]"); TtsBridge.onStructuredTextExtracted("[]");
} }

View file

@ -31,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -151,6 +158,7 @@ fun HomeScreen(
val context = LocalContext.current val context = LocalContext.current
val customTabUriHandler = remember { CustomTabUriHandler(context) } val customTabUriHandler = remember { CustomTabUriHandler(context) }
var showCloseAllTabsDialog by remember { mutableStateOf(false) } var showCloseAllTabsDialog by remember { mutableStateOf(false) }
var showAppThemePanel by remember { mutableStateOf(false) }
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -326,7 +334,8 @@ fun HomeScreen(
} else { } else {
showStrictFilterDialog = true showStrictFilterDialog = true
} }
} },
onAppThemeClick = { showAppThemePanel = true }
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -499,6 +508,19 @@ fun HomeScreen(
onDismiss = { showStrictFilterDialog = false } 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) { if (showAboutDialog) {
@ -840,11 +862,7 @@ fun RecentFileCard(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.FB2) && !item.title.isNullOrBlank()) { text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName,
item.title
} else {
item.displayName
},
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -883,7 +901,8 @@ fun DefaultTopAppBar(
onRecentFilesLimitChange: (Int) -> Unit, onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit, onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit, onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) }
@ -900,6 +919,11 @@ fun DefaultTopAppBar(
} }
} }
}, actions = { }, actions = {
Box {
IconButton(onClick = onAppThemeClick) {
Icon(painterResource(id = R.drawable.palette), contentDescription = "App Theme")
}
}
// Recent Files Limit Menu // Recent Files Limit Menu
Box { Box {
IconButton(onClick = { showLimitMenu = true }) { IconButton(onClick = { showLimitMenu = true }) {
@ -1542,3 +1566,353 @@ fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit)
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } 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)
}
}
}
}
}
}

View file

@ -37,10 +37,13 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController 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 com.aryan.reader.ui.theme.AppTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -78,7 +81,21 @@ class MainActivity : ComponentActivity() {
} }
setContent { 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( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background

View file

@ -32,6 +32,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialCancellationException
@ -113,6 +114,7 @@ import java.util.UUID
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
private const val KEY_RENDER_MODE = "render_mode" private const val KEY_RENDER_MODE = "render_mode"
@ -136,6 +138,24 @@ enum class AddBooksSource(val displayName: String) {
UNSHELVED("Unshelved"), ALL_BOOKS("All Books") 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 { enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT 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 showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK", val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false, 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<CustomAppTheme> = emptyList()
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -355,7 +380,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} ?: emptyList(), } ?: emptyList(),
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", 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 title = displayName
if (type == FileType.PDF) { 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 pdfCoverGenerator = PdfCoverGenerator(appContext)
val coverBitmap = pdfCoverGenerator.generateCover(uri) val coverBitmap = pdfCoverGenerator.generateCover(uri)
if (coverBitmap != null) { if (coverBitmap != null) {
@ -4491,6 +4547,82 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(useStrictFileFilter = enabled) } _internalState.update { it.copy(useStrictFileFilter = enabled) }
} }
private fun loadCustomAppThemes(prefs: SharedPreferences): List<CustomAppTheme> {
val jsonString = prefs.getString(KEY_CUSTOM_APP_THEMES, "[]") ?: "[]"
val themes = mutableListOf<CustomAppTheme>()
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<CustomAppTheme>) {
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? { suspend fun getAuthToken(): String? {
return authRepository.getIdToken() 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_ACTIVE_TAB = "active_tab_book_id"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" 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_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( val SUPPORTED_MIME_TYPES = arrayOf(
"application/pdf", "application/epub+zip", "application/x-mobipocket-ebook", "application/pdf", "application/epub+zip", "application/x-mobipocket-ebook",

View file

@ -9,6 +9,7 @@ import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@ -109,6 +110,28 @@ class MetadataExtractionWorker(
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
title = item.displayName 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 -> { FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook( val book = odtParser.createOdtBook(

View file

@ -126,20 +126,21 @@ class AutoScrollJsBridge(
} }
} }
@Suppress("unused") // function used by JavaScript @Suppress("unused")
class TtsJsBridge( class TtsJsBridge(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val ttsStructuredTextHandler: suspend (String) -> Unit private val ttsStructuredTextHandler: suspend (String) -> Unit
) { ) {
@JavascriptInterface @JavascriptInterface
fun onStructuredTextExtracted(json: String) { 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 != "[]") { if (json.isNotBlank() && json != "[]") {
scope.launch { scope.launch(kotlinx.coroutines.Dispatchers.Default) {
ttsStructuredTextHandler(json) ttsStructuredTextHandler(json)
} }
} else { } 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("[]") 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") @SuppressLint("SetJavaScriptEnabled")
@Composable @Composable
fun ChapterWebView( fun ChapterWebView(
@ -350,6 +362,7 @@ fun ChapterWebView(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onNoteRequested: (String?) -> Unit, onNoteRequested: (String?) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit, onContentReadyForSummarization: suspend (String) -> Unit,
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont, currentFontFamily: ReaderFont,
customFontPath: String? = null, customFontPath: String? = null,
currentTextAlign: ReaderTextAlign, currentTextAlign: ReaderTextAlign,
@ -537,6 +550,15 @@ fun ChapterWebView(
consoleMessage?.let { consoleMessage?.let {
val message = it.message() val message = it.message()
when { 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") -> { message.startsWith("BookmarkDiagnosis") -> {
Timber.tag("BookmarkDiagnosis") Timber.tag("BookmarkDiagnosis")
.d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") .d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
@ -625,6 +647,12 @@ fun ChapterWebView(
AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge" AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge"
) )
addJavascriptInterface(
FootnoteJsBridge { html ->
this.post { onFootnoteRequested(html) }
}, "FootnoteBridge"
)
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest? view: WebView?, request: WebResourceRequest?

View file

@ -20,6 +20,7 @@
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
import android.content.Context import android.content.Context
import android.widget.TextView
import timber.log.Timber import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn 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.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray import org.json.JSONArray
@ -906,3 +911,86 @@ 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()
}
)
}
}
}
}
}

View file

@ -62,6 +62,7 @@ suspend fun loadChapterContent(
val (headContent, chunks) = if (htmlFile.exists()) { val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8") val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html() val head = doc.head().html()
doc.select("script").remove()
val bodyNodes = doc.body().childNodes().toList() val bodyNodes = doc.body().childNodes().toList()
val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes -> val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() } chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }

View file

@ -498,6 +498,7 @@ fun EpubReaderHost(
var pendingNoteForNewHighlight by remember { mutableStateOf(false) } var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf<String?>(null) } var highlightToNoteCfi by remember { mutableStateOf<String?>(null) }
var activeFootnoteHtml by remember { mutableStateOf<String?>(null) }
var showJustifyWarningDialog by remember { mutableStateOf(false) } var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) }
@ -509,6 +510,7 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) } var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) } var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) } var showVisualOptionsSheet by remember { mutableStateOf(false) }
var removeEdgePadding by remember { mutableStateOf(loadRemoveEdgePadding(context)) } var removeEdgePadding by remember { mutableStateOf(loadRemoveEdgePadding(context)) }
@ -809,7 +811,6 @@ fun EpubReaderHost(
var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) } var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) }
var userStoppedTts by remember { mutableStateOf(false) } var userStoppedTts by remember { mutableStateOf(false) }
var skipChapterRequest by remember { mutableStateOf(false) }
var ttsChapterIndex by remember { mutableStateOf<Int?>(null) } var ttsChapterIndex by remember { mutableStateOf<Int?>(null) }
var searchHighlightTarget by remember { mutableStateOf<SearchResult?>(null) } var searchHighlightTarget by remember { mutableStateOf<SearchResult?>(null) }
@ -878,7 +879,7 @@ fun EpubReaderHost(
var activeFragmentId by remember { mutableStateOf<String?>(null) } var activeFragmentId by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current 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) { var currentScrollYPosition by rememberSaveable(epubBook.title) {
mutableIntStateOf(0) 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 searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val speakerPlayer = remember(context, scope) { val speakerPlayer = remember(context, scope) {
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
@ -1135,7 +1122,7 @@ fun EpubReaderHost(
} }
fun startTts() { fun startTts() {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
return return
} }
@ -1193,7 +1180,7 @@ fun EpubReaderHost(
) )
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { 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 showInsufficientCreditsDialog = true
return return
} }
@ -1275,17 +1262,23 @@ fun EpubReaderHost(
ttsChapterIndex = ttsChapterIndex, ttsChapterIndex = ttsChapterIndex,
onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex }, onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex },
onNavigateToChapter = { nextIndex -> onNavigateToChapter = { nextIndex ->
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex")
initialScrollTargetForChapter = ChapterScrollPosition.START initialScrollTargetForChapter = ChapterScrollPosition.START
cfiToLoad = null cfiToLoad = null
currentScrollYPosition = 0 currentScrollYPosition = 0
currentScrollHeightValue = 0 currentScrollHeightValue = 0
currentChapterIndex = nextIndex currentChapterIndex = nextIndex
}, },
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, onToggleTtsStartOnLoad = { shouldStart ->
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ttsShouldStartOnChapterLoad set to: $shouldStart")
ttsShouldStartOnChapterLoad = shouldStart
},
userStoppedTts = userStoppedTts, userStoppedTts = userStoppedTts,
scope = scope, scope = scope,
currentTtsMode = currentTtsMode, currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() } getAuthToken = { viewModel.getAuthToken() },
locatorConverter = locatorConverter,
epubBook = epubBook
) )
TtsHighlightHandler( TtsHighlightHandler(
@ -2411,6 +2404,8 @@ fun EpubReaderHost(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else if (chapterChunks.isNotEmpty()) { } else if (chapterChunks.isNotEmpty()) {
var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) }
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
val targetIdx = loadUpToChunkIndex val targetIdx = loadUpToChunkIndex
val startIdx = 0 val startIdx = 0
@ -2569,8 +2564,9 @@ fun EpubReaderHost(
Timber.d("Auto-save enabled immediately.") Timber.d("Auto-save enabled immediately.")
} }
if (ttsShouldStartOnChapterLoad) { if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) {
Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).")
hasRequestedExtractionForThisChapter = true
scope.launch { scope.launch {
delay(200) delay(200)
webViewRefForTts?.evaluateJavascript( webViewRefForTts?.evaluateJavascript(
@ -2773,7 +2769,7 @@ fun EpubReaderHost(
onTtsTextReady = { jsonString -> onTtsTextReady = { jsonString ->
scope.launch { scope.launch {
val token = viewModel.getAuthToken() 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<TtsChunk>() val ttsChunks = mutableListOf<TtsChunk>()
try { try {
val jsonArray = JSONArray(jsonString) val jsonArray = JSONArray(jsonString)
@ -2808,18 +2804,21 @@ fun EpubReaderHost(
Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}")
if (ttsChunks.isNotEmpty()) { if (ttsChunks.isNotEmpty()) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false ttsShouldStartOnChapterLoad = false
return@launch return@launch
} }
ttsShouldStartOnChapterLoad = false ttsShouldStartOnChapterLoad = false
val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title userStoppedTts = false
val chapterTitle = chapters.getOrNull(targetChapterIndex)?.title
val coverUriString = coverImagePath?.let { val coverUriString = coverImagePath?.let {
Uri.fromFile(File(it)).toString() Uri.fromFile(File(it)).toString()
} }
ttsChapterIndex = currentChapterIndex ttsChapterIndex = targetChapterIndex
ttsController.start( ttsController.start(
chunks = ttsChunks, chunks = ttsChunks,
bookTitle = epubBook.title, bookTitle = epubBook.title,
@ -2830,12 +2829,16 @@ fun EpubReaderHost(
authToken = token authToken = token
) )
} else { } 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) { if (ttsShouldStartOnChapterLoad) {
Timber.d("Empty chapter detected during continuous TTS. Requesting skip." Timber.d("Empty chapter detected during start. Advancing UI to next chapter.")
) val nextIdx = targetChapterIndex + 1
skipChapterRequest = true 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, isProUser = isProUser,
isOss = BuildConfig.FLAVOR == "oss", isOss = BuildConfig.FLAVOR == "oss",
onShowDictionaryUpsellDialog = { onShowDictionaryUpsellDialog = {
@ -3241,6 +3247,9 @@ fun EpubReaderHost(
pendingNoteForNewHighlight = true pendingNoteForNewHighlight = true
} }
}, },
onFootnoteRequested = { html ->
activeFootnoteHtml = html
},
onHighlightDeleted = { cfi -> onHighlightDeleted = { cfi ->
val toRemove = userHighlights.find { it.cfi == cfi } val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) { 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) CustomTopBanner(bannerMessage = bannerMessage)
} }
} }
@ -4452,6 +4470,11 @@ fun EpubReaderHost(
removeEdgePadding = it removeEdgePadding = it
saveRemoveEdgePadding(context, it) saveRemoveEdgePadding(context, it)
}, },
pullToTurnMultiplier = pullToTurnMultiplier,
onPullToTurnMultiplierChange = {
pullToTurnMultiplier = it
savePullToTurnMultiplier(context, it)
},
onDismiss = { showVisualOptionsSheet = false } onDismiss = { showVisualOptionsSheet = false }
) )
} }

View file

@ -248,6 +248,18 @@ fun loadPullToTurn(context: Context): Boolean {
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true) 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 { fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -751,6 +763,8 @@ fun VisualOptionsSheet(
onPullToTurnChange: (Boolean) -> Unit, onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean, removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit, onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -807,21 +821,42 @@ fun VisualOptionsSheet(
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
) { ) {
Row( Column {
modifier = Modifier.padding(16.dp), Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
horizontalArrangement = Arrangement.SpaceBetween .fillMaxWidth()
) { .clickable { onPullToTurnChange(!pullToTurnEnabled) }
Column(modifier = Modifier.weight(1f)) { .padding(16.dp),
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium) verticalAlignment = Alignment.CenterVertically,
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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) })
} }
} }

View file

@ -28,9 +28,9 @@ import androidx.annotation.OptIn
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember
import androidx.core.content.edit import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
@ -101,11 +101,9 @@ fun TtsSessionObserver(
chapters: List<EpubChapter>, chapters: List<EpubChapter>,
epubBookTitle: String, epubBookTitle: String,
coverImagePath: String?, coverImagePath: String?,
// Vertical Mode Dependencies
webViewRef: WebView?, webViewRef: WebView?,
loadedChunkCount: Int, loadedChunkCount: Int,
totalChunksInChapter: Int, totalChunksInChapter: Int,
// Paginated Mode Dependencies
paginator: IPaginator?, paginator: IPaginator?,
pagerState: PagerState, pagerState: PagerState,
ttsChapterIndex: Int?, ttsChapterIndex: Int?,
@ -115,59 +113,94 @@ fun TtsSessionObserver(
userStoppedTts: Boolean, userStoppedTts: Boolean,
scope: CoroutineScope, scope: CoroutineScope,
currentTtsMode: TtsMode, 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) { DisposableEffect(ttsController) {
val wasPlaying = prevTtsState.value.isPlaying val job = scope.launch {
val isPlaying = ttsState.isPlaying var wasPlaying = false
val sessionFinished = ttsState.sessionFinished var wasSessionFinished = false
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (isReaderSource) { ttsController.ttsState.collect { currentState ->
if (sessionFinished && !wasSessionFinished) { val isPlaying = currentState.isPlaying
Timber.d("TTS finished naturally. Checking for next content.") val sessionFinished = currentState.sessionFinished
val sessionEndedByStop = currentState.sessionEndedByStop
val isReaderSource = currentState.playbackSource == "READER"
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { if (isReaderSource) {
handleVerticalAutoAdvance( if (sessionFinished && !wasSessionFinished) {
webViewRef = webViewRef, Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("TTS finished naturally. Checking for next content.")
loadedChunkCount = loadedChunkCount,
totalChunksInChapter = totalChunksInChapter, if (currentRenderModeState.value == RenderMode.VERTICAL_SCROLL) {
currentTtsChapterIndex = ttsChapterIndex, handleVerticalAutoAdvance(
totalChapters = chapters.size, webViewRef = webViewRefState.value,
onNavigateToNextChapter = { nextIndex -> loadedChunkCount = loadedChunkCountState.value,
onToggleTtsStartOnLoad(true) totalChunksInChapter = totalChunksInChapterState.value,
onNavigateToChapter(nextIndex) currentTtsChapterIndex = ttsChapterIndexState.value,
}, totalChapters = chaptersState.value.size,
onStopTts = { onTtsChapterIndexChange(null) } onNavigateToNextChapter = { nextIndex ->
) onToggleTtsStartOnLoadState.value(false)
} else if (currentRenderMode == RenderMode.PAGINATED) { onNavigateToChapterState.value(nextIndex)
handlePaginatedAutoAdvance( },
ttsController = ttsController, onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
paginator = paginator, onStopTts = { onTtsChapterIndexChangeState.value(null) },
pagerState = pagerState, chapters = chaptersState.value,
chapters = chapters, currentTtsMode = currentTtsMode,
currentTtsChapterIndex = ttsChapterIndex, epubBookTitle = epubBookTitle,
epubBookTitle = epubBookTitle, coverImagePath = coverImagePath,
coverImagePath = coverImagePath, getAuthToken = getAuthToken,
onUpdateTtsChapter = onTtsChapterIndexChange, ttsController = ttsController,
scope = scope, scope = this,
ttsMode = currentTtsMode, locatorConverter = locatorConverterState.value,
getAuthToken = getAuthToken epubBook = epubBookState.value
) )
} } else if (currentRenderModeState.value == RenderMode.PAGINATED) {
} else if (wasPlaying && !isPlaying && !sessionFinished) { handlePaginatedAutoAdvance(
// Playback stopped/paused ttsController = ttsController,
if (userStoppedTts || sessionEndedByStop) { paginator = paginatorState.value,
Timber.d("TTS stopped by user/stop command.") pagerState = pagerStateState.value,
onTtsChapterIndexChange(null) 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?, ttsChapterIndex: Int?,
scope: CoroutineScope scope: CoroutineScope
) { ) {
// 1. Vertical & General Highlighting (WebView)
LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) { LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) {
val text = ttsState.currentText val text = ttsState.currentText
val cfi = ttsState.sourceCfi val cfi = ttsState.sourceCfi
@ -195,7 +227,6 @@ fun TtsHighlightHandler(
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) { if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text) val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi) val escapedCfi = escapeJsString(cfi)
// Use window.highlightFromCfi defined in epub_reader.js
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);" val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
webViewRef?.evaluateJavascript(jsCommand, null) webViewRef?.evaluateJavascript(jsCommand, null)
} else { } else {
@ -205,7 +236,6 @@ fun TtsHighlightHandler(
} }
} }
// 2. Paginated Page Turning (Sentence/Fragment level)
LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) { LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) {
if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect
@ -218,7 +248,7 @@ fun TtsHighlightHandler(
if (targetPage != null && targetPage != pagerState.currentPage) { if (targetPage != null && targetPage != pagerState.currentPage) {
scope.launch { scope.launch {
pagerState.animateScrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} }
} }
} }
@ -226,6 +256,7 @@ fun TtsHighlightHandler(
// --- Internal Helper Functions --- // --- Internal Helper Functions ---
@OptIn(UnstableApi::class)
private fun handleVerticalAutoAdvance( private fun handleVerticalAutoAdvance(
webViewRef: WebView?, webViewRef: WebView?,
loadedChunkCount: Int, loadedChunkCount: Int,
@ -233,20 +264,88 @@ private fun handleVerticalAutoAdvance(
currentTtsChapterIndex: Int?, currentTtsChapterIndex: Int?,
totalChapters: Int, totalChapters: Int,
onNavigateToNextChapter: (Int) -> Unit, onNavigateToNextChapter: (Int) -> Unit,
onStopTts: () -> Unit onUpdateTtsChapter: (Int?) -> Unit,
onStopTts: () -> Unit,
chapters: List<EpubChapter>,
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) { if (currentTtsChapterIndex == null) return
Timber.d("Vertical: Loading next chunk for TTS.")
webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null) scope.launch {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ val currentState = ttsController.ttsState.value
webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) val lastReadCfi = currentState.sourceCfi
}, 500)
} else { if (loadedChunkCount < totalChunksInChapter) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.")
Timber.d("Vertical: Chapter finished, moving to next.") val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
onNavigateToNextChapter(currentTtsChapterIndex + 1)
} else { if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) {
Timber.d("Vertical: End of book.") 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() onStopTts()
} }
} }
@ -268,7 +367,7 @@ private fun handlePaginatedAutoAdvance(
getAuthToken: suspend () -> String? getAuthToken: suspend () -> String?
) { ) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { 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 { scope.launch {
var chapterToTry = currentTtsChapterIndex + 1 var chapterToTry = currentTtsChapterIndex + 1
@ -283,19 +382,18 @@ private fun handlePaginatedAutoAdvance(
while (chapterToTry < chapters.size) { while (chapterToTry < chapters.size) {
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry] val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) { if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage) // CHANGED: Fire-and-forget scroll without frame blocking!
delay(300) launch { pagerState.scrollToPage(targetPage) }
} }
val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry) val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry)
if (!nextChapterChunks.isNullOrEmpty()) { 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) onUpdateTtsChapter(chapterToTry)
val chapterTitle = chapters.getOrNull(chapterToTry)?.title val chapterTitle = chapters.getOrNull(chapterToTry)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
val token = getAuthToken() val token = getAuthToken()
ttsController.start( ttsController.start(
@ -310,20 +408,13 @@ private fun handlePaginatedAutoAdvance(
foundContent = true foundContent = true
break break
} else { } else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").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)
}
}
chapterToTry++ chapterToTry++
} }
} }
if (!foundContent) { if (!foundContent) {
Timber.d("Paginated: No more content found.") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: No more content found.")
onUpdateTtsChapter(null) onUpdateTtsChapter(null)
} }
} }

View file

@ -69,6 +69,15 @@ class InteractiveWebView(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean { override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed") 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() onSingleTap()
return true return true
} }
@ -117,12 +126,16 @@ class InteractiveWebView(
} }
if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) { 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( evaluateJavascript(
"javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);", "javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);",
null null
) )
val cancelEvent = MotionEvent.obtain(event)
cancelEvent.action = MotionEvent.ACTION_CANCEL
super.onTouchEvent(cancelEvent)
cancelEvent.recycle()
} }
} }

View file

@ -224,6 +224,61 @@ class LocatorConverter(
return bestMatch return bestMatch
} }
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
val chunks = mutableListOf<TtsChunk>()
fun traverse(blocks: List<SemanticBlock>) {
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) { suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)

View file

@ -55,9 +55,11 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -65,8 +67,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect 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.isSpecified
import androidx.compose.ui.geometry.toRect import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.BlendMode 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.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix 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.Path
import androidx.compose.ui.graphics.PathEffect 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.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin 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.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath 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.unit.toSize
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.ImageLoader import coil.ImageLoader
@ -144,6 +156,7 @@ import com.aryan.reader.epubreader.ReaderTextAlign
import com.aryan.reader.epubreader.TtsHighlightInfo import com.aryan.reader.epubreader.TtsHighlightInfo
import com.aryan.reader.epubreader.UserHighlight import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -151,12 +164,17 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import org.jsoup.Jsoup
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.net.URI
import java.net.URLDecoder
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt
data class PaginatedSelection( data class PaginatedSelection(
val startBlockIndex: Int, val startBlockIndex: Int,
@ -173,7 +191,7 @@ data class PaginatedSelection(
) )
class ReactiveBlockMap( class ReactiveBlockMap(
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = androidx.compose.runtime.mutableStateMapOf() private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate { ) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
var tick by mutableIntStateOf(0) var tick by mutableIntStateOf(0)
@ -522,6 +540,7 @@ fun PaginatedReaderScreen(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onStartTtsFromSelection: (String, Int) -> Unit, onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit, onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
@ -540,15 +559,15 @@ fun PaginatedReaderScreen(
val textureBitmap = remember(activeTextureId) { val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id -> activeTextureId?.let { id ->
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId -> 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) { val textureModifier = if (textureBitmap != null) {
Modifier.drawBehind { Modifier.drawBehind {
val brush = androidx.compose.ui.graphics.ShaderBrush( val brush = ShaderBrush(
androidx.compose.ui.graphics.ImageShader(textureBitmap, androidx.compose.ui.graphics.TileMode.Repeated, androidx.compose.ui.graphics.TileMode.Repeated) ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
) )
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f) drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
} }
@ -849,7 +868,8 @@ fun PaginatedReaderScreen(
val result = paginator.getPageContent(pageIndex) val result = paginator.getPageContent(pageIndex)
val duration = System.currentTimeMillis() - startTime val duration = System.currentTimeMillis() - startTime
if (duration > 16) { 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 result
}, },
@ -866,7 +886,96 @@ fun PaginatedReaderScreen(
} }
}, },
onLinkClick = { currentChapterPath, href, onNavComplete -> 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, onTap = onTap,
isProUser = isProUser, isProUser = isProUser,
@ -1772,7 +1881,7 @@ internal fun PaginatedReaderContent(
if (uiState.totalPageCount > 0) { if (uiState.totalPageCount > 0) {
uiState.generation uiState.generation
var rootCoords by androidx.compose.runtime.remember { mutableStateOf<LayoutCoordinates?>(null) } var rootCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) } var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) }
val magnifierModifier = if (magnifierCenter.isSpecified) { val magnifierModifier = if (magnifierCenter.isSpecified) {
@ -3108,7 +3217,7 @@ internal fun PaginatedReaderContent(
density density
) { SmartPopupPositionProvider(menuAnchorRect, density) }, ) { SmartPopupPositionProvider(menuAnchorRect, density) },
onDismissRequest = { activeSelection = null }, onDismissRequest = { activeSelection = null },
properties = androidx.compose.ui.window.PopupProperties( properties = PopupProperties(
dismissOnClickOutside = false dismissOnClickOutside = false
) )
) { ) {
@ -3354,7 +3463,7 @@ internal fun PaginatedReaderContent(
activeDragHandle activeDragHandle
} }
val latestUpdateSelection by androidx.compose.runtime.rememberUpdatedState(updateSelection) val latestUpdateSelection by rememberUpdatedState(updateSelection)
listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType -> listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType ->
val isStart = handleType == SelectionHandle.START val isStart = handleType == SelectionHandle.START
@ -3455,7 +3564,7 @@ internal fun PaginatedReaderContent(
contentDescription = if (isStart) "Start handle" else "End handle", contentDescription = if (isStart) "Start handle" else "End handle",
modifier = Modifier.size(36.dp).graphicsLayer { modifier = Modifier.size(36.dp).graphicsLayer {
rotationZ = if (isStart) 30f else -30f rotationZ = if (isStart) 30f else -30f
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) transformOrigin = TransformOrigin(0.5f, 0f)
}, },
tint = Color(0xFF1976D2) tint = Color(0xFF1976D2)
) )
@ -3867,7 +3976,7 @@ private fun Modifier.realisticBookPage(
if (pageOffset != 0f) { if (pageOffset != 0f) {
shadowElevation = 10f shadowElevation = 10f
shape = androidx.compose.ui.graphics.RectangleShape shape = RectangleShape
clip = false clip = false
} }
} }
@ -3897,7 +4006,7 @@ private fun Modifier.realisticBookPage(
val dx = w - dragX val dx = w - dragX
val dy = cornerY - dragY val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy) val nLen = sqrt(dx * dx + dy * dy)
// CRITICAL GEOMETRY LOG // CRITICAL GEOMETRY LOG
if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens 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) { if (blockStyle.backgroundColor.isSpecified && blockStyle.backgroundColor != Color.Transparent) {
val bgPath = Path().apply { val bgPath = Path().apply {
addRoundRect( addRoundRect(
androidx.compose.ui.geometry.RoundRect( RoundRect(
rect = size.toRect(), rect = size.toRect(),
topLeft = androidx.compose.ui.geometry.CornerRadius(tlRadius, tlRadius), topLeft = CornerRadius(tlRadius, tlRadius),
topRight = androidx.compose.ui.geometry.CornerRadius(trRadius, trRadius), topRight = CornerRadius(trRadius, trRadius),
bottomRight = androidx.compose.ui.geometry.CornerRadius(brRadius, brRadius), bottomRight = CornerRadius(brRadius, brRadius),
bottomLeft = androidx.compose.ui.geometry.CornerRadius(blRadius, blRadius) bottomLeft = CornerRadius(blRadius, blRadius)
) )
) )
} }
@ -4134,7 +4243,7 @@ fun Modifier.drawCssBorders(
startAngle = 180f, sweepAngle = 90f, startAngle = 180f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f), 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) style = Stroke(width = topWidth)
) )
} }
@ -4145,7 +4254,7 @@ fun Modifier.drawCssBorders(
startAngle = 270f, sweepAngle = 90f, startAngle = 270f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f), 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) style = Stroke(width = topWidth)
) )
} }
@ -4156,7 +4265,7 @@ fun Modifier.drawCssBorders(
startAngle = 0f, sweepAngle = 90f, startAngle = 0f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)), 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) style = Stroke(width = bottomWidth)
) )
} }
@ -4167,7 +4276,7 @@ fun Modifier.drawCssBorders(
startAngle = 90f, sweepAngle = 90f, startAngle = 90f, sweepAngle = 90f,
useCenter = false, useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)), 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) style = Stroke(width = bottomWidth)
) )
} }

View file

@ -34,6 +34,10 @@ import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap 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.graphics.RectF
import android.net.Uri import android.net.Uri
import android.os.Build 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.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit 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.GraphicEq
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown 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"), DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"), THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"), LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"), FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"), SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "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) 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) { private fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) } prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
@ -1238,6 +1231,8 @@ fun PdfViewerScreen(
val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) } var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) } 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 isFullScreen by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) } var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
@ -1311,7 +1306,6 @@ fun PdfViewerScreen(
LaunchedEffect(bookId) { LaunchedEffect(bookId) {
isScrollLocked = loadPdfScrollLocked(context, bookId) isScrollLocked = loadPdfScrollLocked(context, bookId)
isFullScreen = loadPdfFullScreen(context, bookId)
lockedState = loadPdfLockedState(context, bookId) lockedState = loadPdfLockedState(context, bookId)
} }
@ -1529,42 +1523,84 @@ fun PdfViewerScreen(
} }
val window = (view.context as? Activity)?.window val window = (view.context as? Activity)?.window
LaunchedEffect(isFullScreen) { LaunchedEffect(systemUiMode, showBars) {
if (window != null) { if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
if (isFullScreen) { when (systemUiMode) {
showBars = false SystemUiMode.DEFAULT -> {
insetsController.hide(WindowInsetsCompat.Type.systemBars()) insetsController.show(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior = }
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE SystemUiMode.SYNC -> {
} else { if (showBars) {
showBars = true insetsController.show(WindowInsetsCompat.Type.systemBars())
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 dockHeight = 64.dp
val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() } 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<SearchResult> = { 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, dockLocation,
snapPreviewLocation, snapPreviewLocation,
isEditMode, isEditMode,
isDockDragging isDockDragging,
showStandardBars,
systemUiMode,
showBars,
statusBarHeightDp
) { ) {
derivedStateOf { if (!isEditMode) {
if (!isEditMode) { var h = 0.dp
0.dp if (showStandardBars) {
} else { h += 56.dp
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
if (isStickyTop || isPreviewingTop) dockHeight else 0.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( val verticalFooterHeight by remember(
dockLocation, dockLocation,
snapPreviewLocation, snapPreviewLocation,
@ -2266,7 +2302,7 @@ fun PdfViewerScreen(
} }
selectedTextBoxId = null selectedTextBoxId = null
} else { } else {
if (!isFullScreen && !(isMusicianMode && isAutoScrollModeActive)) { if (!(isMusicianMode && isAutoScrollModeActive)) {
showBars = !showBars showBars = !showBars
Timber.d("Vertical Reader Clicked. showBars now: $showBars") Timber.d("Vertical Reader Clicked. showBars now: $showBars")
} }
@ -3702,14 +3738,9 @@ fun PdfViewerScreen(
var activeQuery by remember { mutableStateOf("") } var activeQuery by remember { mutableStateOf("") }
val dummySearcher: suspend (String) -> List<SearchResult> = { emptyList() }
val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
var smartSearchResult by remember { mutableStateOf<SmartSearchResult?>(null) } var smartSearchResult by remember { mutableStateOf<SmartSearchResult?>(null) }
var currentPdfSearchResult by remember { mutableStateOf<SearchResult?>(null) } var currentPdfSearchResult by remember { mutableStateOf<SearchResult?>(null) }
val density = LocalDensity.current
val navBarHeight = WindowInsets.systemBars.getBottom(density) val navBarHeight = WindowInsets.systemBars.getBottom(density)
val imeHeight = WindowInsets.ime.getBottom(density) val imeHeight = WindowInsets.ime.getBottom(density)
@ -3733,16 +3764,7 @@ fun PdfViewerScreen(
} }
} }
val statusBarHeight = WindowInsets.statusBars.getTop(density) val topScrollLimitPx = with(density) { verticalHeaderHeight.toPx() }
val topBarHeightPx = with(density) { 56.dp.toPx() }
val topScrollLimitPx = remember(showBars, statusBarHeight) {
if (showBars) {
statusBarHeight + topBarHeightPx
} else {
0f
}
}
LaunchedEffect(searchState.searchQuery, currentBookId) { LaunchedEffect(searchState.searchQuery, currentBookId) {
val query = searchState.searchQuery val query = searchState.searchQuery
@ -3902,10 +3924,7 @@ fun PdfViewerScreen(
onNavigateBack() onNavigateBack()
} }
isFullScreen -> { showVisualOptionsSheet -> showVisualOptionsSheet = false
isFullScreen = false
savePdfFullScreen(context, bookId, false)
}
showReindexDialog != null -> showReindexDialog = null 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( ModalNavigationDrawer(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
@ -4487,10 +4500,8 @@ fun PdfViewerScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
BoxWithConstraints(modifier = Modifier BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())) {
.fillMaxSize() IntSize(constraints.maxWidth, constraints.maxHeight)
.padding(paddingValues)) {
IntSize(constraints.maxWidth, constraints.maxHeight)
val boxConstraints = constraints val boxConstraints = constraints
val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat() val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat()
val boxMaxHeightFloat = boxConstraints.maxHeight.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)) { if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary), text = stringResource(R.string.tooltip_dictionary),
@ -5877,6 +5872,24 @@ fun PdfViewerScreen(
HorizontalDivider() 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)) { if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) }, 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) { if (isEditMode) {
val density = LocalDensity.current 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) { if (showCustomizeToolsSheet) {
PdfCustomizeToolsSheet( PdfCustomizeToolsSheet(
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
@ -8716,3 +8703,49 @@ 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 }
)
}
}
}

View file

@ -596,7 +596,9 @@ class TtsPlaybackManager(
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1 val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) { 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()) { if (isLastChunkInSession || textChunks.isEmpty()) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
nextState = nextState.copy(sessionFinished = true) nextState = nextState.copy(sessionFinished = true)
} else { } else {
val nextIdx = currentChunkIndex + 1 val nextIdx = currentChunkIndex + 1

View file

@ -19,9 +19,7 @@
*/ */
package com.aryan.reader.ui.theme package com.aryan.reader.ui.theme
import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
@ -29,7 +27,10 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.materialkolor.PaletteStyle
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.materialkolor.dynamicColorScheme
private val lightScheme = lightColorScheme( private val lightScheme = lightColorScheme(
primary = primaryLight, primary = primaryLight,
@ -111,11 +112,21 @@ private val darkScheme = darkColorScheme(
fun AppTheme( fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
seedColor: Color? = null,
contrastLevel: Double = 0.0,
textDimFactor: Float = 1.0f,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme = when { val colorScheme = when {
seedColor != null -> dynamicColorScheme(
seedColor = seedColor,
isDark = darkTheme,
contrastLevel = contrastLevel,
style = PaletteStyle.Fidelity
)
dynamicColor && supportsDynamicColor -> { dynamicColor && supportsDynamicColor -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@ -124,8 +135,22 @@ fun AppTheme(
else -> lightScheme 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = finalColorScheme,
typography = AppTypography, typography = AppTypography,
content = content content = content
) )