General improvements (#164)

* Optimized PDF tile rendering performance and quality.

* Added support for adjusting TTS voice speed and pitch.

* Updated the TTS settings UI and playback logic to support real-time parameter adjustments.

* Added horizontal scrolling to the PDF viewer bottom toolbar and updated tool arrangement to use fixed spacing.

* Refined cross-page selection and text extraction in `PaginatedReader`

* Updated EPUB reader styling logic to refine typography and layout controls.

* Bump version to 1.0.42(43)
This commit is contained in:
Aryan 2026-04-10 20:23:39 +05:30 committed by GitHub
parent 12d50bb68d
commit 17a0097d4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 559 additions and 168 deletions

View file

@ -396,6 +396,8 @@ class FolderSyncWorker(
return when {
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2

View file

@ -23,6 +23,7 @@ class MetadataExtractionWorker(
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
companion object {
const val WORK_NAME = "MetadataExtractionWorker"
@ -109,6 +110,18 @@ class MetadataExtractionWorker(
}
title = item.displayName
}
FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
isFlat = type == FileType.FODT,
parseContent = false
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
else -> {
title = item.displayName
}

View file

@ -24,17 +24,12 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import timber.log.Timber
import android.speech.tts.TextToSpeech
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Switch
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -68,6 +63,8 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -85,10 +82,14 @@ import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -99,11 +100,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -130,11 +137,13 @@ import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.roundToInt
enum class ReaderTool(val title: String, val category: String) {
@ -504,6 +513,8 @@ fun EpubReaderBottomBar(
isTtsSessionActive: Boolean,
ttsState: TtsState,
isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
onOpenTtsControls: () -> Unit,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
@ -658,6 +669,19 @@ fun EpubReaderBottomBar(
) else stringResource(R.string.content_desc_resume_tts)
)
}
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.BASE) {
TooltipIconButton(
text = "Voice Adjustments",
description = "Adjust voice speed and pitch",
onClick = onOpenTtsControls
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = "Voice Adjustments"
)
}
}
}
}
}
@ -1421,4 +1445,198 @@ fun CustomizeToolsSheet(
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsControlsSheet(
onDismiss: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
ttsController: com.aryan.reader.tts.TtsController
) {
val context = androidx.compose.ui.platform.LocalContext.current
val ttsState by ttsController.ttsState.collectAsState()
// Local TTS for Sample Playback
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
var isTtsReady by remember { mutableStateOf(false) }
var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) }
// Initialize Local TTS for samples
DisposableEffect(Unit) {
val instance = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
isTtsReady = true
try {
val preferredVoiceName = loadNativeVoice(context)
if (preferredVoiceName != null) {
tts?.voices?.find { it.name == preferredVoiceName }?.let { targetVoice ->
tts?.voice = targetVoice
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to apply preferred voice in sample")
}
}
}
tts = instance
onDispose { instance.shutdown() }
}
val saveAndSlice = {
saveTtsSpeechRate(context, rate)
saveTtsPitch(context, pitch)
ttsController.sliceAndRetainPosition()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text("Voice Adjustments", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
// Rate Slider
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Speed (${"%.1f".format(rate)}x)", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
rate = 1.0f
ttsController.pause()
saveAndSlice()
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset Speed")
}
}
Slider(
value = rate,
onValueChange = {
rate = it
// Pause playback immediately when user starts dragging
if (!isDraggingRate) {
isDraggingRate = true
ttsController.pause()
}
},
onValueChangeFinished = {
isDraggingRate = false
saveAndSlice()
},
valueRange = 0.5f..3.0f,
steps = 24 // Creates 0.1 increments
)
// Pitch Slider
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Pitch (${"%.1f".format(pitch)}x)", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
pitch = 1.0f
ttsController.pause()
saveAndSlice()
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset Pitch")
}
}
Slider(
value = pitch,
onValueChange = {
pitch = it
if (!isDraggingPitch) {
isDraggingPitch = true
ttsController.pause()
}
},
onValueChangeFinished = {
isDraggingPitch = false
saveAndSlice()
},
valueRange = 0.5f..2.0f,
steps = 14 // Creates 0.1 increments
)
Spacer(Modifier.height(8.dp))
// Play Sample Button
Button(
onClick = {
if (ttsState.isPlaying) ttsController.pause()
tts?.setSpeechRate(rate)
tts?.setPitch(pitch)
tts?.speak("This is how your current voice settings sound.", TextToSpeech.QUEUE_FLUSH, null, null)
},
modifier = Modifier.fillMaxWidth(),
enabled = isTtsReady,
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Icon(Icons.Default.GraphicEq, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Play Sample")
}
Spacer(Modifier.height(24.dp))
// Central Play/Pause Control for the Book
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = {
tts?.stop()
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
},
modifier = Modifier.size(64.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
if (ttsState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
strokeWidth = 3.dp
)
} else {
Icon(
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) "Pause Book" else "Play Book",
modifier = Modifier.size(32.dp)
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = if (ttsState.isPlaying) "Pause Book" else "Resume Book",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(24.dp))
OutlinedButton(
onClick = {
onDismiss()
onOpenDeviceVoiceSettings()
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Settings, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("System Voice / Engine Settings")
}
}
}
}

View file

@ -1042,6 +1042,7 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
@ -3722,6 +3723,8 @@ fun EpubReaderHost(
ttsState = ttsState,
isProUser = isProUser,
hiddenTools = hiddenTools,
currentTtsMode = currentTtsMode,
onOpenTtsControls = { showTtsControlsSheet = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@ -4176,6 +4179,14 @@ fun EpubReaderHost(
)
}
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
)
}
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,

View file

@ -114,8 +114,30 @@ private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch"
fun saveTtsSpeechRate(context: Context, rate: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(TTS_SPEECH_RATE_KEY, rate) }
}
fun loadTtsSpeechRate(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(TTS_SPEECH_RATE_KEY, 1.0f)
}
fun saveTtsPitch(context: Context, pitch: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(TTS_PITCH_KEY, pitch) }
}
fun loadTtsPitch(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
}
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
ORIGINAL("original", "Original", "Original"),
@ -559,7 +581,7 @@ fun ReaderTextFormatPanel(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Size
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
Text("Font Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
@ -567,31 +589,31 @@ fun ReaderTextFormatPanel(
steps = 24,
modifier = Modifier.weight(1f)
)
Text("%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
Text(if (currentFontSize in 0.99f..1.01f) "Orig" else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
// Lines
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Lines", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
Text("Line Height", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f,
steps = 14,
valueRange = 1.0f..3.0f,
steps = 19,
modifier = Modifier.weight(1f)
)
Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
Text(if (currentLineHeight <= 1.01f) "Orig" else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
// Paragraph Gap
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gap", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
Text("Paragraph Gap", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentParagraphGap,
onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f,
steps = 12,
steps = 29,
modifier = Modifier.weight(1f)
)
Text("%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
Text(if (currentParagraphGap in 0.99f..1.01f) "Orig" else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
}
Spacer(Modifier.height(8.dp))

View file

@ -1145,6 +1145,26 @@ private fun getHighlightOffsetsInBlock(
return null
}
private fun List<ContentBlock>.extractTextBlocks(): List<TextContentBlock> {
val result = mutableListOf<TextContentBlock>()
for (block in this) {
when (block) {
is WrappingContentBlock -> result.addAll(block.paragraphsToWrap)
is FlexContainerBlock -> result.addAll(block.children.extractTextBlocks())
is TableBlock -> {
block.rows.forEach { row ->
row.forEach { cell ->
result.addAll(cell.content.extractTextBlocks())
}
}
}
is TextContentBlock -> result.add(block)
else -> {}
}
}
return result
}
@Composable
private fun TextWithEmphasis(
text: AnnotatedString,
@ -1577,13 +1597,22 @@ internal fun PaginatedReaderContent(
}
val textBlocksOnPage =
pageContent?.content?.filterIsInstance<TextContentBlock>()
pageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
val lastTextBlock = textBlocksOnPage.lastOrNull()
val lastBlockAbs = lastTextBlock?.let {
when (it) {
is ParagraphBlock -> it.startCharOffsetInSource
is HeaderBlock -> it.startCharOffsetInSource
is QuoteBlock -> it.startCharOffsetInSource
is ListItemBlock -> it.startCharOffsetInSource
}
}
// Strict Trigger Check - Custom Selection
LaunchedEffect(activeSelection, lastTextBlock, isDraggingHandle) {
if (isDraggingHandle && activeSelection != null && lastTextBlock != null && activeSelection!!.endBlockIndex == lastTextBlock.blockIndex) {
if (isDraggingHandle && activeSelection != null && lastTextBlock != null &&
activeSelection!!.endBlockIndex == lastTextBlock.blockIndex &&
activeSelection!!.endBlockCharOffset == lastBlockAbs) {
if (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) {
if (crossPageTriggerInfo?.first != pageIndex) {
Timber.tag("TextSelectionDiag")
@ -1607,7 +1636,7 @@ internal fun PaginatedReaderContent(
val content = pageContent ?: return@LaunchedEffect
val firstTextBlock =
content.content.filterIsInstance<TextContentBlock>()
content.content.extractTextBlocks()
.firstOrNull { it.cfi != null } ?: run {
pendingCrossPageSelection = null
return@LaunchedEffect

View file

@ -120,9 +120,12 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -169,7 +172,7 @@ data class EmbeddedAnnotation(
data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
enum class LinkSource {
ANNOTATION, TEXT_CONTENT
@ -371,6 +374,7 @@ data class PageSelectionData(
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
)
@OptIn(FlowPreview::class)
@Suppress("unused")
@Composable
internal fun PdfPageComposable(
@ -1104,12 +1108,15 @@ internal fun PdfPageComposable(
try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect ->
snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect ->
delay(150)
val tileCalcStart = System.nanoTime()
if (!isActive) return@collect
if (!isActive) return@collectLatest
if (isScrolling && effectiveScale > 1f) {
return@collect
return@collectLatest
}
val pxTl: Float
@ -1131,20 +1138,16 @@ internal fun PdfPageComposable(
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
return@collect
return@collectLatest
}
} else {
val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f
pxTl =
(((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyTl =
(((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxBr =
(((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyBr =
(((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
}
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
@ -1154,13 +1157,11 @@ internal fun PdfPageComposable(
val requiredTileIds = mutableSetOf<Int>()
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
val endCol =
((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
val endCol = ((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
val endRow =
((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
)
val endRow = ((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
)
for (row in startRow until endRow) {
for (col in startCol until endCol) {
@ -1170,6 +1171,9 @@ internal fun PdfPageComposable(
val currentTileIds = tiles.map { it.tileId }.toSet()
val scaleTolerance = 0.05f
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
if (duration > 2f) {
Timber.tag("PdfPerformance").d(
@ -1177,9 +1181,9 @@ internal fun PdfPageComposable(
)
}
if (requiredTileIds != currentTileIds) {
if (requiredTileIds != validCurrentTileIds) {
val tilesToRenderIds = requiredTileIds - currentTileIds
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
if (tilesToRecycleIds.isNotEmpty()) {
@ -1205,15 +1209,12 @@ internal fun PdfPageComposable(
(col + 1) * tileSizePx,
(row + 1) * tileSizePx
)
val tileRenderSize =
(tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
val fullPageRenderWidth =
(actualBitmapWidthPx * effectiveScale).toInt()
val fullPageRenderHeight =
(actualBitmapHeightPx * effectiveScale).toInt()
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
@ -1226,12 +1227,19 @@ internal fun PdfPageComposable(
renderAnnot = true
)
val newTile = PdfTile(tileBitmap, tileRect, tileId)
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
var handedOver = false
try {
withContext(Dispatchers.Main) {
tiles = tiles + newTile
val oldTile = tiles.find { it.tileId == tileId }
tiles = tiles.filter { it.tileId != tileId } + newTile
handedOver = true
oldTile?.let {
coroutineScope.launch(Dispatchers.IO) {
PdfBitmapPool.recycle(it.bitmap)
}
}
}
} finally {
if (!handedOver) {
@ -2746,6 +2754,7 @@ internal fun PdfPageComposable(
} else if (mode == 2 && pointerCount > 1) {
val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(1f, 4f)
Timber.tag("PdfZoomIssue").v("Gesture Scaling: old=$oldScale, new=$newScale, zoomChange=$zoomChange")
val previousCentroid = event.calculateCentroid(useCurrent = false)
if (previousCentroid != Offset.Unspecified) {
@ -3328,8 +3337,12 @@ internal fun PdfPageComposable(
currentPageRotation = 0
val MAX_BASE_DIMEN = 3000
var baseW = scaledWidth
var baseH = scaledHeight
val baseRenderScale = 1.5f
var baseW = (scaledWidth * baseRenderScale).toInt()
var baseH = (scaledHeight * baseRenderScale).toInt()
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
@ -3394,8 +3407,12 @@ internal fun PdfPageComposable(
}
val MAX_BASE_DIMEN = 3000
var baseW = scaledWidth
var baseH = scaledHeight
val baseRenderScale = 1.5f
var baseW = (scaledWidth * baseRenderScale).toInt()
var baseH = (scaledHeight * baseRenderScale).toInt()
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1)

View file

@ -32,6 +32,8 @@ import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune
import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap
import android.graphics.RectF
import android.net.Uri
@ -78,6 +80,7 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
@ -284,6 +287,7 @@ import com.aryan.reader.countWords
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.TtsControlsSheet
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
@ -1322,6 +1326,7 @@ fun PdfViewerScreen(
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
DisposableEffect(isKeepScreenOn) {
@ -6322,12 +6327,15 @@ fun PdfViewerScreen(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
val bottomBarScrollState = rememberScrollState()
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
.padding(horizontal = 8.dp)
.horizontalScroll(bottomBarScrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Slider Navigation Trigger
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
@ -6498,58 +6506,73 @@ fun PdfViewerScreen(
// TTS
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start_desc),
onClick = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
ttsController.stop()
} else {
startTtsWithPermissionCheck(null, null)
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start_desc),
onClick = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
ttsController.stop()
} else {
startTtsWithPermissionCheck(null, null)
}
}) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close)
else painterResource(
id = R.drawable.text_to_speech
),
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
)
}
}) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close)
else painterResource(
id = R.drawable.text_to_speech
),
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
)
}
}
// TTS Pause/Resume Button
if (isTtsSessionActive) {
TooltipIconButton(
text = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause)
else
stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = {
if (ttsState.isPlaying) {
ttsController.pause()
} else {
ttsController.resume()
if (isTtsSessionActive) {
TooltipIconButton(
text = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause)
else
stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = {
if (ttsState.isPlaying) {
ttsController.pause()
} else {
ttsController.resume()
}
}, enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(
id = if (ttsState.isPlaying) R.drawable.pause
else R.drawable.play
), contentDescription = if (ttsState.isPlaying) "Pause TTS"
else "Resume TTS"
)
}
// Tune button for BASE mode
if (currentTtsMode == TtsPlaybackManager.TtsMode.BASE) {
TooltipIconButton(
text = "Voice Adjustments",
description = "Adjust voice speed and pitch",
onClick = { showTtsControlsSheet = true }
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = "Voice Adjustments"
)
}
}
}
}, enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(
id = if (ttsState.isPlaying) R.drawable.pause
else R.drawable.play
), contentDescription = if (ttsState.isPlaying) "Pause TTS"
else "Resume TTS"
)
}
}
} else {
Spacer(Modifier.size(48.dp))
}
// Error Message Area
@ -7458,6 +7481,14 @@ fun PdfViewerScreen(
)
}
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
)
}
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,

View file

@ -23,6 +23,8 @@ import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import com.aryan.reader.epubreader.loadTtsPitch
import com.aryan.reader.epubreader.loadTtsSpeechRate
import com.aryan.reader.loadNativeVoice
import timber.log.Timber
import kotlinx.coroutines.CompletableDeferred
@ -198,6 +200,9 @@ class BaseTtsSynthesizer(private val context: Context) {
applyPreferredVoice()
tts?.setSpeechRate(loadTtsSpeechRate(context))
tts?.setPitch(loadTtsPitch(context))
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)

View file

@ -230,6 +230,16 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
}
fun flushPrefetch() {
Timber.d("UI sending FLUSH_PREFETCH command.")
mediaController?.sendCustomCommand(FLUSH_PREFETCH_COMMAND, Bundle.EMPTY)
}
fun sliceAndRetainPosition() {
Timber.d("UI sending SLICE_CURRENT_AND_RELOAD command.")
mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY)
}
override fun onEvents(player: Player, events: Player.Events) {
updateStateFromController()
}

View file

@ -52,8 +52,10 @@ import kotlinx.coroutines.delay
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
val CHANGE_SPEAKER_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER", Bundle.EMPTY)
val FLUSH_PREFETCH_COMMAND = SessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH", Bundle.EMPTY)
private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY)
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@ -137,6 +139,8 @@ class TtsPlaybackManager(
.add(STOP_TTS_COMMAND)
.add(CHANGE_SPEAKER_COMMAND)
.add(CHANGE_TTS_MODE_COMMAND)
.add(FLUSH_PREFETCH_COMMAND)
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
.build()
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@ -203,10 +207,71 @@ class TtsPlaybackManager(
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
handleChangeTtsMode(newMode)
}
FLUSH_PREFETCH_COMMAND -> {
Timber.d("Flushing prefetched TTS chunks for new parameters.")
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch(Dispatchers.IO) {
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
if (currentIdx == C.INDEX_UNSET) return@launch
val keysToRemove = audioFiles.keys.filter { it > currentIdx }
keysToRemove.forEach { key ->
audioFiles.remove(key)?.delete()
}
withContext(Dispatchers.Main) {
prefetchNextChunkAudio(currentIdx)
}
}
}
SLICE_CURRENT_AND_RELOAD_COMMAND -> {
handleSliceAndReload()
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
private fun handleSliceAndReload() {
val currentIdx = player.currentMediaItemIndex
if (currentIdx == C.INDEX_UNSET) return
val offset = _ttsState.value.currentWordStartOffset
val currentChunk = textChunks.getOrNull(currentIdx) ?: return
preparationJob?.cancel()
wordTrackingJob?.cancel()
player.stop()
player.clearMediaItems()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
preparationJob = scope.launch {
clearAudioFiles()
if (offset == -1) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
return@launch
}
val relativeOffset = (offset - currentChunk.startOffsetInSource).coerceIn(0, currentChunk.text.length)
if (relativeOffset >= currentChunk.text.length) {
if (currentIdx + 1 < textChunks.size) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx + 1, playWhenReady = false)
}
return@launch
}
val slicedText = currentChunk.text.substring(relativeOffset)
val newChunk = currentChunk.copy(text = slicedText, startOffsetInSource = offset)
val mutableChunks = textChunks.toMutableList()
mutableChunks[currentIdx] = newChunk
textChunks = mutableChunks.toList()
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
}
}
private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return
currentTtsMode = newMode