App themes (#217)

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

* Improved TTS chapter transition handling

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

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

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

* Added support for footnotes in the paginated EPUB reader.

* Implemented a customizable app theming system.

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

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

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

View file

@ -31,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -44,6 +45,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -55,7 +57,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
@ -116,6 +120,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@ -125,6 +131,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
@ -151,6 +158,7 @@ fun HomeScreen(
val context = LocalContext.current
val customTabUriHandler = remember { CustomTabUriHandler(context) }
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
var showAppThemePanel by remember { mutableStateOf(false) }
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -326,7 +334,8 @@ fun HomeScreen(
} else {
showStrictFilterDialog = true
}
}
},
onAppThemeClick = { showAppThemePanel = true }
)
} else {
ContextualTopAppBar(
@ -499,6 +508,19 @@ fun HomeScreen(
onDismiss = { showStrictFilterDialog = false }
)
}
if (showAppThemePanel) {
AppThemeBottomSheet(
uiState = uiState,
onThemeModeChanged = viewModel::setAppThemeMode,
onContrastOptionChanged = viewModel::setAppContrastOption,
onTextDimFactorChanged = viewModel::setAppTextDimFactor,
onSeedColorChanged = viewModel::setAppSeedColor,
onCustomThemeAdded = viewModel::addCustomAppTheme,
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
onDismiss = { showAppThemePanel = false }
)
}
}
}
if (showAboutDialog) {
@ -840,11 +862,7 @@ fun RecentFileCard(
verticalArrangement = Arrangement.Center
) {
Text(
text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.FB2) && !item.title.isNullOrBlank()) {
item.title
} else {
item.displayName
},
text = item.customName ?: item.title?.takeIf { it.isNotBlank() } ?: item.displayName,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -883,7 +901,8 @@ fun DefaultTopAppBar(
onRecentFilesLimitChange: (Int) -> Unit,
onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit
onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
@ -900,6 +919,11 @@ fun DefaultTopAppBar(
}
}
}, actions = {
Box {
IconButton(onClick = onAppThemeClick) {
Icon(painterResource(id = R.drawable.palette), contentDescription = "App Theme")
}
}
// Recent Files Limit Menu
Box {
IconButton(onClick = { showLimitMenu = true }) {
@ -1541,4 +1565,354 @@ fun StrictFilterConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit)
confirmButton = { TextButton(onClick = onConfirm) { Text("Enable") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppThemeBottomSheet(
uiState: ReaderScreenState,
onThemeModeChanged: (AppThemeMode) -> Unit,
onContrastOptionChanged: (AppContrastOption) -> Unit,
onTextDimFactorChanged: (Float) -> Unit,
onSeedColorChanged: (Color?) -> Unit,
onCustomThemeAdded: (CustomAppTheme) -> Unit,
onCustomThemeDeleted: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showCreateDialog by remember { mutableStateOf(false) }
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
) {
Text(
text = "App Theme",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Appearance", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) {
AppThemeMode.entries.forEach { mode ->
val isSelected = uiState.appThemeMode == mode
Box(
modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onThemeModeChanged(mode) },
contentAlignment = Alignment.Center
) {
Text(mode.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
}
}
}
Spacer(Modifier.height(24.dp))
Text("Contrast", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).padding(4.dp)) {
AppContrastOption.entries.forEach { option ->
val isSelected = uiState.appContrastOption == option
Box(
modifier = Modifier.weight(1f).fillMaxHeight().clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onContrastOptionChanged(option) },
contentAlignment = Alignment.Center
) {
Text(option.displayName, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
}
}
}
Spacer(Modifier.height(24.dp))
Text("Text Brightness", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactor,
onValueChange = onTextDimFactorChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
Spacer(Modifier.height(24.dp))
Text("Color Scheme", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
item {
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
isSelected = uiState.appSeedColor == null,
label = "Dynamic",
onClick = { onSeedColorChanged(null) }
)
}
val presets = listOf(
"Ocean" to Color(0xFF00668B),
"Mint" to Color(0xFF006C4C),
"Rose" to Color(0xFF9C4146),
"Sepia" to Color(0xFF705D49),
"Amethyst" to Color(0xFF9B59B6),
"Amber" to Color(0xFFFFC107),
"Sapphire" to Color(0xFF0F52BA)
)
items(presets.size) { i ->
val (label, color) = presets[i]
ThemeSwatch(
color = color,
isSelected = uiState.appSeedColor == color,
label = label,
onClick = { onSeedColorChanged(color) }
)
}
}
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add Custom Theme", tint = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(8.dp))
if (uiState.customAppThemes.isEmpty()) {
Text("No custom themes yet.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(uiState.customAppThemes) { theme ->
ThemeSwatch(
color = theme.seedColor,
isSelected = uiState.appSeedColor == theme.seedColor,
label = theme.name,
onClick = { onSeedColorChanged(theme.seedColor) },
onDelete = { onCustomThemeDeleted(theme.id) }
)
}
}
}
}
}
if (showCreateDialog) {
CreateAppThemeDialog(
onDismiss = { showCreateDialog = false },
onSave = { name, color ->
onCustomThemeAdded(CustomAppTheme(id = System.currentTimeMillis().toString(), name = name, seedColor = color))
showCreateDialog = false
}
)
}
}
@Composable
fun ThemeSwatch(
color: Color,
isSelected: Boolean,
label: String,
onClick: () -> Unit,
onDelete: (() -> Unit)? = null
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(56.dp)
.background(color, CircleShape)
.border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(Icons.Default.Check, contentDescription = null, tint = if (color.luminance() > 0.5f) Color.Black else Color.White)
}
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 64.dp))
if (onDelete != null) {
Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(16.dp).clickable { onDelete() }, tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun CreateAppThemeDialog(
initialColor: Color = Color(0xFF6750A4),
onDismiss: () -> Unit,
onSave: (String, Color) -> Unit
) {
var name by remember { mutableStateOf("") }
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by androidx.compose.runtime.mutableFloatStateOf(initialHsv[0])
var saturation by androidx.compose.runtime.mutableFloatStateOf(initialHsv[1])
var value by androidx.compose.runtime.mutableFloatStateOf(initialHsv[2])
val currentColor by remember {
androidx.compose.runtime.derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
Color(android.graphics.Color.HSVToColor(255, hsv))
}
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.padding(20.dp)
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Create App Theme",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Spacer(Modifier.height(16.dp))
androidx.compose.material3.OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = currentColor,
focusedLabelColor = currentColor,
)
)
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s -> hue = h; saturation = s },
modifier = Modifier.fillMaxWidth().height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier.fillMaxWidth().height(24.dp).clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = initialColor,
newColor = currentColor,
modifier = Modifier.width(64.dp).height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("HEX", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(label = "R", value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "G", value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(label = "B", value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Button(
onClick = { onSave(name.ifBlank { "Custom Theme" }, currentColor) },
colors = ButtonDefaults.buttonColors(containerColor = currentColor)
) {
Text("Save", color = if (currentColor.luminance() > 0.5f) Color.Black else Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}

View file

@ -37,10 +37,13 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.aryan.reader.data.PlatformFeaturesRepository // Import the new repo
import com.aryan.reader.data.PlatformFeaturesRepository
import com.aryan.reader.ui.theme.AppTheme
import kotlinx.coroutines.launch
import timber.log.Timber
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
class MainActivity : ComponentActivity() {
@ -78,7 +81,21 @@ class MainActivity : ComponentActivity() {
}
setContent {
AppTheme {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val darkTheme = when (uiState.appThemeMode) {
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
AppThemeMode.SYSTEM -> isSystemInDarkTheme()
}
AppTheme(
darkTheme = darkTheme,
dynamicColor = uiState.appSeedColor == null,
seedColor = uiState.appSeedColor,
contrastLevel = uiState.appContrastOption.value,
textDimFactor = uiState.appTextDimFactor
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background

View file

@ -32,6 +32,7 @@ import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.credentials.exceptions.GetCredentialCancellationException
@ -113,6 +114,7 @@ import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.flow.distinctUntilChanged
private const val KEY_RENDER_MODE = "render_mode"
@ -136,6 +138,24 @@ enum class AddBooksSource(val displayName: String) {
UNSHELVED("Unshelved"), ALL_BOOKS("All Books")
}
enum class AppThemeMode(val displayName: String) {
SYSTEM("System"),
LIGHT("Light"),
DARK("Dark")
}
enum class AppContrastOption(val displayName: String, val value: Double) {
STANDARD("Standard", 0.0),
MEDIUM("Medium", 0.5),
HIGH("High", 1.0)
}
data class CustomAppTheme(
val id: String,
val name: String,
val seedColor: androidx.compose.ui.graphics.Color
)
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
}
@ -245,6 +265,11 @@ data class ReaderScreenState(
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactor: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList()
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -355,7 +380,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} ?: emptyList(),
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK",
useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false)
useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false),
appThemeMode = try {
AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name)
} catch (_: Exception) { AppThemeMode.SYSTEM },
appContrastOption = try {
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
} catch (_: Exception) { AppContrastOption.STANDARD },
appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f),
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
customAppThemes = loadCustomAppThemes(prefs)
)
)
@ -2631,6 +2665,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
title = displayName
if (type == FileType.PDF) {
try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
val extractedTitle = meta.title
if (!extractedTitle.isNullOrBlank()) {
title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank()) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument)
}
} catch (e: Exception) {
Timber.e(e, "Failed to extract PDF title using PdfiumCore")
}
val pdfCoverGenerator = PdfCoverGenerator(appContext)
val coverBitmap = pdfCoverGenerator.generateCover(uri)
if (coverBitmap != null) {
@ -4491,6 +4547,82 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(useStrictFileFilter = enabled) }
}
private fun loadCustomAppThemes(prefs: SharedPreferences): List<CustomAppTheme> {
val jsonString = prefs.getString(KEY_CUSTOM_APP_THEMES, "[]") ?: "[]"
val themes = mutableListOf<CustomAppTheme>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
themes.add(
CustomAppTheme(
id = obj.getString("id"),
name = obj.getString("name"),
seedColor = androidx.compose.ui.graphics.Color(obj.getInt("seedColor"))
)
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse custom app themes")
}
return themes
}
fun setAppThemeMode(mode: AppThemeMode) {
_internalState.update { it.copy(appThemeMode = mode) }
prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) }
}
fun setAppContrastOption(option: AppContrastOption) {
_internalState.update { it.copy(appContrastOption = option) }
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
}
fun setAppTextDimFactor(factor: Float) {
_internalState.update { it.copy(appTextDimFactor = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) }
}
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
_internalState.update { it.copy(appSeedColor = color) }
prefs.edit {
if (color == null) {
remove(KEY_APP_SEED_COLOR)
} else {
putInt(KEY_APP_SEED_COLOR, (color).toArgb())
}
}
}
fun addCustomAppTheme(theme: CustomAppTheme) {
val current = _internalState.value.customAppThemes.filter { it.id != theme.id } + theme
_internalState.update { it.copy(customAppThemes = current) }
saveCustomAppThemes(current)
setAppSeedColor(theme.seedColor)
}
fun deleteCustomAppTheme(themeId: String) {
val current = _internalState.value.customAppThemes.filter { it.id != themeId }
_internalState.update { it.copy(customAppThemes = current) }
saveCustomAppThemes(current)
if (_internalState.value.appSeedColor != null && !current.any { it.seedColor == _internalState.value.appSeedColor }) {
setAppSeedColor(null)
}
}
private fun saveCustomAppThemes(themes: List<CustomAppTheme>) {
val jsonArray = JSONArray()
themes.forEach { theme ->
val obj = JSONObject().apply {
put("id", theme.id)
put("name", theme.name)
put("seedColor", (theme.seedColor).toArgb())
}
jsonArray.put(obj)
}
prefs.edit { putString(KEY_CUSTOM_APP_THEMES, jsonArray.toString()) }
}
suspend fun getAuthToken(): String? {
return authRepository.getIdToken()
}
@ -4518,6 +4650,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_ACTIVE_TAB = "active_tab_book_id"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_APP_THEME_MODE = "app_theme_mode"
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
private const val KEY_APP_SEED_COLOR = "app_seed_color"
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
val SUPPORTED_MIME_TYPES = arrayOf(
"application/pdf", "application/epub+zip", "application/x-mobipocket-ebook",

View file

@ -9,6 +9,7 @@ import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -109,6 +110,28 @@ class MetadataExtractionWorker(
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
title = item.displayName
try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
val extractedTitle = meta.title
if (!extractedTitle.isNullOrBlank()) {
title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank()) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument)
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore")
}
}
FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook(

View file

@ -126,20 +126,21 @@ class AutoScrollJsBridge(
}
}
@Suppress("unused") // function used by JavaScript
@Suppress("unused")
class TtsJsBridge(
private val scope: CoroutineScope,
private val ttsStructuredTextHandler: suspend (String) -> Unit
) {
@JavascriptInterface
fun onStructuredTextExtracted(json: String) {
Timber.tag("TTS_LIST_DIAG").d("Bridge received JSON: $json")
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS Bridge received JSON. Length: ${json.length}")
if (json.isNotBlank() && json != "[]") {
scope.launch {
scope.launch(kotlinx.coroutines.Dispatchers.Default) {
ttsStructuredTextHandler(json)
}
} else {
scope.launch {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").w("JS Bridge received empty or blank JSON. This may trigger a chapter skip.")
scope.launch(kotlinx.coroutines.Dispatchers.Default) {
ttsStructuredTextHandler("[]")
}
}
@ -301,6 +302,17 @@ class AiJsBridge(
}
}
@Suppress("unused")
class FootnoteJsBridge(
private val onFootnoteRequestCallback: (String) -> Unit
) {
@JavascriptInterface
fun onFootnoteRequested(htmlContent: String) {
Timber.tag("FootnoteDiag").d("Kotlin Bridge received footnote content. Length: ${htmlContent.length}")
onFootnoteRequestCallback(htmlContent)
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ChapterWebView(
@ -350,6 +362,7 @@ fun ChapterWebView(
onSearch: (String) -> Unit,
onNoteRequested: (String?) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
currentTextAlign: ReaderTextAlign,
@ -537,6 +550,15 @@ fun ChapterWebView(
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("FootnoteDiag:") -> {
Timber.tag("FootnoteDiag")
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
}
message.startsWith("TTS_CHAPTER_CHANGE_DIAG:") -> {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS -> ${message.substringAfter("TTS_CHAPTER_CHANGE_DIAG: ")}")
}
message.startsWith("BookmarkDiagnosis") -> {
Timber.tag("BookmarkDiagnosis")
.d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}")
@ -625,6 +647,12 @@ fun ChapterWebView(
AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge"
)
addJavascriptInterface(
FootnoteJsBridge { html ->
this.post { onFootnoteRequested(html) }
}, "FootnoteBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?

View file

@ -20,6 +20,7 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.widget.TextView
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -54,15 +55,19 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
@ -905,4 +910,87 @@ fun PaginatedTextSelectionMenu(
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FootnoteBottomSheet(
htmlContent: String,
effectiveBg: Color,
effectiveText: Color,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val maxSheetHeight = configuration.screenHeightDp.dp * 0.5f
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = effectiveBg,
contentColor = effectiveText,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.label_note),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Surface(
color = effectiveText.copy(alpha = 0.05f),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, effectiveText.copy(alpha = 0.1f)),
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
AndroidView(
factory = { context ->
TextView(context).apply {
setTextColor(effectiveText.toArgb())
textSize = 16f
setLineSpacing(0f, 1.4f)
isVerticalScrollBarEnabled = false
movementMethod = null
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(
htmlContent,
HtmlCompat.FROM_HTML_MODE_COMPACT
).trimEnd()
}
)
}
}
}
}
}

View file

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

View file

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

View file

@ -248,6 +248,18 @@ fun loadPullToTurn(context: Context): Boolean {
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
}
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(PULL_TO_TURN_MULTIPLIER_KEY, multiplier) }
}
fun loadPullToTurnMultiplier(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -751,6 +763,8 @@ fun VisualOptionsSheet(
onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -807,21 +821,42 @@ fun VisualOptionsSheet(
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
}
AnimatedVisibility(visible = pullToTurnEnabled) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f))
Text("Pull Distance to Change Chapter", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Short", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Slider(
value = pullToTurnMultiplier,
onValueChange = onPullToTurnMultiplierChange,
valueRange = 0.5f..2.0f,
steps = 14,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp)
)
Text("Long", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
}
}

View file

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

View file

@ -69,6 +69,15 @@ class InteractiveWebView(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed")
val hitTestResult = this@InteractiveWebView.hitTestResult
val type = hitTestResult.type
if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
Timber.d("Tap was on a link. Consuming tap, not toggling app bars.")
return true
}
onSingleTap()
return true
}
@ -117,12 +126,16 @@ class InteractiveWebView(
}
if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) {
Timber.d("Drag operation started ($currentDragOperation), disabling text selection."
)
Timber.d("Drag operation started ($currentDragOperation), disabling text selection.")
evaluateJavascript(
"javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);",
null
)
val cancelEvent = MotionEvent.obtain(event)
cancelEvent.action = MotionEvent.ACTION_CANCEL
super.onTouchEvent(cancelEvent)
cancelEvent.recycle()
}
}

View file

@ -224,6 +224,61 @@ class LocatorConverter(
return bestMatch
}
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
val chunks = mutableListOf<TtsChunk>()
fun traverse(blocks: List<SemanticBlock>) {
for (block in blocks) {
if (block is SemanticTextBlock && block.cfi != null && block.text.isNotBlank()) {
val subChunks = com.aryan.reader.tts.splitTextIntoChunks(block.text)
var currentSearchIndex = 0
for (chunkText in subChunks) {
val firstWord = chunkText.trim().substringBefore(' ')
val relativeOffset = if (firstWord.isNotEmpty()) {
val idx = block.text.indexOf(firstWord, currentSearchIndex)
if (idx != -1) idx else currentSearchIndex
} else {
currentSearchIndex
}
chunks.add(
TtsChunk(
text = chunkText,
sourceCfi = block.cfi!!,
startOffsetInSource = block.startCharOffsetInSource + relativeOffset
)
)
currentSearchIndex = relativeOffset + chunkText.length
}
}
when (block) {
is SemanticFlexContainer -> traverse(block.children)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> traverse(cell.content) } }
is SemanticList -> traverse(block.items)
is SemanticWrappingBlock -> traverse(block.paragraphsToWrap)
else -> Unit
}
}
}
traverse(allBlocks)
chunks
}
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)

View file

@ -55,9 +55,11 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@ -65,8 +67,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.BlendMode
@ -74,10 +79,16 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
@ -127,6 +138,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import coil.ImageLoader
@ -144,6 +156,7 @@ import com.aryan.reader.epubreader.ReaderTextAlign
import com.aryan.reader.epubreader.TtsHighlightInfo
import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -151,12 +164,17 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import org.jsoup.Jsoup
import timber.log.Timber
import java.io.File
import java.net.URI
import java.net.URLDecoder
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sqrt
data class PaginatedSelection(
val startBlockIndex: Int,
@ -173,7 +191,7 @@ data class PaginatedSelection(
)
class ReactiveBlockMap(
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = androidx.compose.runtime.mutableStateMapOf()
private val delegate: MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> = mutableStateMapOf()
) : MutableMap<String, Triple<TextLayoutResult, LayoutCoordinates, TextContentBlock>> by delegate {
var tick by mutableIntStateOf(0)
@ -522,6 +540,7 @@ fun PaginatedReaderScreen(
onSearch: (String) -> Unit,
onStartTtsFromSelection: (String, Int) -> Unit,
onNoteRequested: (String?) -> Unit,
onFootnoteRequested: (String) -> Unit,
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
@ -540,15 +559,15 @@ fun PaginatedReaderScreen(
val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id ->
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId)
ImageBitmap.imageResource(context.resources, resId)
}
}
}
val textureModifier = if (textureBitmap != null) {
Modifier.drawBehind {
val brush = androidx.compose.ui.graphics.ShaderBrush(
androidx.compose.ui.graphics.ImageShader(textureBitmap, androidx.compose.ui.graphics.TileMode.Repeated, androidx.compose.ui.graphics.TileMode.Repeated)
val brush = ShaderBrush(
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
)
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
}
@ -849,7 +868,8 @@ fun PaginatedReaderScreen(
val result = paginator.getPageContent(pageIndex)
val duration = System.currentTimeMillis() - startTime
if (duration > 16) {
Timber.tag("PageTurnDiag").w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}")
Timber.tag("PageTurnDiag")
.w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}")
}
result
},
@ -866,7 +886,96 @@ fun PaginatedReaderScreen(
}
},
onLinkClick = { currentChapterPath, href, onNavComplete ->
paginator.navigateToHref(currentChapterPath, href, onNavComplete)
coroutineScope.launch(Dispatchers.IO) {
var isFootnote = false
var footnoteHtml: String? = null
val sourceChapter =
book.chaptersForPagination.find { it.absPath == currentChapterPath }
if (sourceChapter != null) {
val sourceHtml = sourceChapter.htmlContent.ifEmpty {
try {
File(book.extractionBasePath, sourceChapter.htmlFilePath)
.readText()
} catch (_: Exception) {
""
}
}
if (sourceHtml.isNotEmpty()) {
val doc = Jsoup.parse(sourceHtml)
val safeHref = href.replace("\"", "\\\"")
val aTag = doc.select("a[href=\"$safeHref\"]").first()
if (aTag?.attr("epub:type") == "noteref" || href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
} else if (href.startsWith("#")) {
isFootnote = true
}
if (isFootnote) {
val decodedHref = try {
URLDecoder.decode(href, "UTF-8")
} catch (_: Exception) {
href
}
val parts = decodedHref.split('#', limit = 2)
val pathPart = parts[0]
val anchor = if (parts.size > 1) parts[1] else null
if (anchor != null) {
val targetPath = if (pathPart.isBlank()) currentChapterPath else {
try {
URI(currentChapterPath).resolve(pathPart)
.normalize().path
} catch (_: Exception) {
null
}
}
if (targetPath != null) {
val targetChapter = book.chaptersForPagination.find {
try {
URI(it.absPath).normalize().path == targetPath
} catch (_: Exception) {
false
}
}
if (targetChapter != null) {
val targetHtml = targetChapter.htmlContent.ifEmpty {
try {
File(
book.extractionBasePath,
targetChapter.htmlFilePath
).readText()
} catch (_: Exception) {
""
}
}
if (targetHtml.isNotEmpty()) {
val doc = Jsoup.parse(targetHtml)
val noteEl = doc.getElementById(anchor)
if (noteEl != null) {
footnoteHtml = noteEl.html()
}
}
}
}
}
}
withContext(Dispatchers.Main) {
if (!footnoteHtml.isNullOrBlank()) {
onFootnoteRequested(footnoteHtml)
} else {
paginator.navigateToHref(currentChapterPath, href, onNavComplete)
}
}
}
},
onTap = onTap,
isProUser = isProUser,
@ -1772,7 +1881,7 @@ internal fun PaginatedReaderContent(
if (uiState.totalPageCount > 0) {
uiState.generation
var rootCoords by androidx.compose.runtime.remember { mutableStateOf<LayoutCoordinates?>(null) }
var rootCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var magnifierCenter by remember { mutableStateOf(Offset.Unspecified) }
val magnifierModifier = if (magnifierCenter.isSpecified) {
@ -3108,7 +3217,7 @@ internal fun PaginatedReaderContent(
density
) { SmartPopupPositionProvider(menuAnchorRect, density) },
onDismissRequest = { activeSelection = null },
properties = androidx.compose.ui.window.PopupProperties(
properties = PopupProperties(
dismissOnClickOutside = false
)
) {
@ -3354,7 +3463,7 @@ internal fun PaginatedReaderContent(
activeDragHandle
}
val latestUpdateSelection by androidx.compose.runtime.rememberUpdatedState(updateSelection)
val latestUpdateSelection by rememberUpdatedState(updateSelection)
listOf(SelectionHandle.START, SelectionHandle.END).forEach { handleType ->
val isStart = handleType == SelectionHandle.START
@ -3455,7 +3564,7 @@ internal fun PaginatedReaderContent(
contentDescription = if (isStart) "Start handle" else "End handle",
modifier = Modifier.size(36.dp).graphicsLayer {
rotationZ = if (isStart) 30f else -30f
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f)
transformOrigin = TransformOrigin(0.5f, 0f)
},
tint = Color(0xFF1976D2)
)
@ -3867,7 +3976,7 @@ private fun Modifier.realisticBookPage(
if (pageOffset != 0f) {
shadowElevation = 10f
shape = androidx.compose.ui.graphics.RectangleShape
shape = RectangleShape
clip = false
}
}
@ -3897,7 +4006,7 @@ private fun Modifier.realisticBookPage(
val dx = w - dragX
val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy)
val nLen = sqrt(dx * dx + dy * dy)
// CRITICAL GEOMETRY LOG
if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens
@ -4035,12 +4144,12 @@ fun Modifier.drawCssBorders(
if (blockStyle.backgroundColor.isSpecified && blockStyle.backgroundColor != Color.Transparent) {
val bgPath = Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
RoundRect(
rect = size.toRect(),
topLeft = androidx.compose.ui.geometry.CornerRadius(tlRadius, tlRadius),
topRight = androidx.compose.ui.geometry.CornerRadius(trRadius, trRadius),
bottomRight = androidx.compose.ui.geometry.CornerRadius(brRadius, brRadius),
bottomLeft = androidx.compose.ui.geometry.CornerRadius(blRadius, blRadius)
topLeft = CornerRadius(tlRadius, tlRadius),
topRight = CornerRadius(trRadius, trRadius),
bottomRight = CornerRadius(brRadius, brRadius),
bottomLeft = CornerRadius(blRadius, blRadius)
)
)
}
@ -4134,7 +4243,7 @@ fun Modifier.drawCssBorders(
startAngle = 180f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f),
size = androidx.compose.ui.geometry.Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth),
size = Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
@ -4145,7 +4254,7 @@ fun Modifier.drawCssBorders(
startAngle = 270f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
size = androidx.compose.ui.geometry.Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth),
size = Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
@ -4156,7 +4265,7 @@ fun Modifier.drawCssBorders(
startAngle = 0f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth),
size = Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}
@ -4167,7 +4276,7 @@ fun Modifier.drawCssBorders(
startAngle = 90f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth),
size = Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}

View file

@ -34,6 +34,10 @@ import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings
import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap
import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.loadSystemUiMode
import com.aryan.reader.epubreader.saveSystemUiMode
import com.aryan.reader.epubreader.OptionSegmentedControl
import android.graphics.RectF
import android.net.Uri
import android.os.Build
@ -127,8 +131,6 @@ import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
@ -384,6 +386,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
@ -520,16 +523,6 @@ private fun loadPdfScrollLocked(context: Context, bookId: String): Boolean {
return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false)
}
private fun savePdfFullScreen(context: Context, bookId: String, isFull: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_FULL_SCREEN_PREFIX + bookId, isFull) }
}
private fun loadPdfFullScreen(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_FULL_SCREEN_PREFIX + bookId, false)
}
private fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
@ -1238,6 +1231,8 @@ fun PdfViewerScreen(
val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) }
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
var isFullScreen by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
@ -1311,7 +1306,6 @@ fun PdfViewerScreen(
LaunchedEffect(bookId) {
isScrollLocked = loadPdfScrollLocked(context, bookId)
isFullScreen = loadPdfFullScreen(context, bookId)
lockedState = loadPdfLockedState(context, bookId)
}
@ -1529,42 +1523,84 @@ fun PdfViewerScreen(
}
val window = (view.context as? Activity)?.window
LaunchedEffect(isFullScreen) {
LaunchedEffect(systemUiMode, showBars) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
if (isFullScreen) {
showBars = false
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
showBars = true
insetsController.show(WindowInsetsCompat.Type.systemBars())
when (systemUiMode) {
SystemUiMode.DEFAULT -> {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
SystemUiMode.SYNC -> {
if (showBars) {
insetsController.show(WindowInsetsCompat.Type.systemBars())
} else {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
SystemUiMode.HIDDEN -> {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
}
val dockHeight = 64.dp
val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() }
val density = LocalDensity.current
val verticalHeaderHeight by remember(
val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() }
val dummySearcher: suspend (String) -> List<SearchResult> = { emptyList() }
val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
val showStandardBars = showBars && !isEditMode
val snackbarPadding by animateDpAsState(
targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp,
label = "SnackbarPadding"
)
val targetVerticalHeaderHeight = remember(
dockLocation,
snapPreviewLocation,
isEditMode,
isDockDragging
isDockDragging,
showStandardBars,
systemUiMode,
showBars,
statusBarHeightDp
) {
derivedStateOf {
if (!isEditMode) {
0.dp
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
if (isStickyTop || isPreviewingTop) dockHeight else 0.dp
if (!isEditMode) {
var h = 0.dp
if (showStandardBars) {
h += 56.dp
}
val isStatusBarVisible = when (systemUiMode) {
SystemUiMode.DEFAULT -> true
SystemUiMode.SYNC -> showBars
SystemUiMode.HIDDEN -> false
}
if (isStatusBarVisible) {
h += statusBarHeightDp
}
h
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
if (isStickyTop || isPreviewingTop) dockHeight else 0.dp
}
}
val verticalHeaderHeight by animateDpAsState(
targetValue = targetVerticalHeaderHeight,
animationSpec = tween(durationMillis = 200),
label = "verticalHeaderHeight"
)
val verticalFooterHeight by remember(
dockLocation,
snapPreviewLocation,
@ -2266,7 +2302,7 @@ fun PdfViewerScreen(
}
selectedTextBoxId = null
} else {
if (!isFullScreen && !(isMusicianMode && isAutoScrollModeActive)) {
if (!(isMusicianMode && isAutoScrollModeActive)) {
showBars = !showBars
Timber.d("Vertical Reader Clicked. showBars now: $showBars")
}
@ -3702,14 +3738,9 @@ fun PdfViewerScreen(
var activeQuery by remember { mutableStateOf("") }
val dummySearcher: suspend (String) -> List<SearchResult> = { emptyList() }
val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
var smartSearchResult by remember { mutableStateOf<SmartSearchResult?>(null) }
var currentPdfSearchResult by remember { mutableStateOf<SearchResult?>(null) }
val density = LocalDensity.current
val navBarHeight = WindowInsets.systemBars.getBottom(density)
val imeHeight = WindowInsets.ime.getBottom(density)
@ -3733,16 +3764,7 @@ fun PdfViewerScreen(
}
}
val statusBarHeight = WindowInsets.statusBars.getTop(density)
val topBarHeightPx = with(density) { 56.dp.toPx() }
val topScrollLimitPx = remember(showBars, statusBarHeight) {
if (showBars) {
statusBarHeight + topBarHeightPx
} else {
0f
}
}
val topScrollLimitPx = with(density) { verticalHeaderHeight.toPx() }
LaunchedEffect(searchState.searchQuery, currentBookId) {
val query = searchState.searchQuery
@ -3902,10 +3924,7 @@ fun PdfViewerScreen(
onNavigateBack()
}
isFullScreen -> {
isFullScreen = false
savePdfFullScreen(context, bookId, false)
}
showVisualOptionsSheet -> showVisualOptionsSheet = false
showReindexDialog != null -> showReindexDialog = null
@ -3950,12 +3969,6 @@ fun PdfViewerScreen(
}
}
val showStandardBars = showBars && !isEditMode
val snackbarPadding by animateDpAsState(
targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp,
label = "SnackbarPadding"
)
ModalNavigationDrawer(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
@ -4487,10 +4500,8 @@ fun PdfViewerScreen(
)
}
) { paddingValues ->
BoxWithConstraints(modifier = Modifier
.fillMaxSize()
.padding(paddingValues)) {
IntSize(constraints.maxWidth, constraints.maxHeight)
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())) {
IntSize(constraints.maxWidth, constraints.maxHeight)
val boxConstraints = constraints
val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat()
val boxMaxHeightFloat = boxConstraints.maxHeight.toFloat()
@ -5755,22 +5766,6 @@ fun PdfViewerScreen(
}
}
if (!hiddenTools.contains(PdfReaderTool.FULL_SCREEN.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_fullscreen),
description = stringResource(R.string.tooltip_fullscreen_desc),
onClick = {
isFullScreen = true
savePdfFullScreen(context, bookId, true)
}) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = "Enter Full Screen",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
@ -5877,6 +5872,24 @@ fun PdfViewerScreen(
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = {
showMoreMenu = false
showVisualOptionsSheet = true
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
@ -6612,42 +6625,6 @@ fun PdfViewerScreen(
}
}
// Full Screen Exit Button
AnimatedVisibility(
visible = isFullScreen,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(24.dp)
) {
val isBackgroundDark = displayMode == DisplayMode.PAGINATION || isPdfDarkMode
val fabContainerColor = if (isBackgroundDark) Color.White.copy(alpha = 0.25f)
else Color.Black.copy(alpha = 0.25f)
val fabContentColor = if (isBackgroundDark) Color.White else Color.Black
Surface(
onClick = {
isFullScreen = false
savePdfFullScreen(context, bookId, false)
},
color = fabContainerColor,
contentColor = fabContentColor,
shape = CircleShape,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Exit Full Screen",
modifier = Modifier.size(26.dp)
)
}
}
}
if (isEditMode) {
val density = LocalDensity.current
@ -7994,6 +7971,16 @@ fun PdfViewerScreen(
}
)
}
if (showVisualOptionsSheet) {
PdfVisualOptionsSheet(
systemUiMode = systemUiMode,
onSystemUiModeChange = { mode ->
systemUiMode = mode
saveSystemUiMode(context, mode)
},
onDismiss = { showVisualOptionsSheet = false }
)
}
if (showCustomizeToolsSheet) {
PdfCustomizeToolsSheet(
hiddenTools = hiddenTools,
@ -8715,4 +8702,50 @@ fun PdfCustomizeToolsSheet(
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode,
onSystemUiModeChange: (SystemUiMode) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(12.dp))
OptionSegmentedControl(
options = SystemUiMode.entries,
selectedOption = systemUiMode,
onOptionSelected = onSystemUiModeChange,
getLabel = { it.title }
)
}
}
}

View file

@ -596,7 +596,9 @@ class TtsPlaybackManager(
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
if (isLastChunkInSession || textChunks.isEmpty()) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
nextState = nextState.copy(sessionFinished = true)
} else {
val nextIdx = currentChunkIndex + 1

View file

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