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