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