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

@ -30,8 +30,8 @@ android {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 42 versionCode = 43
versionName = "1.0.41" versionName = "1.0.42"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild { externalNativeBuild {

View file

@ -481,83 +481,60 @@
var newGap = parseFloat(paragraphGap); var newGap = parseFloat(paragraphGap);
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.6; if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0; if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
var fontCss = ""; var fontCss = "";
var selector = "body";
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
var fallback = "sans-serif"; var fallback = "sans-serif";
if (fontFamily === "Merriweather" || fontFamily === "Lora") { if (fontFamily === "Merriweather" || fontFamily === "Lora") {
fallback = "serif"; fallback = "serif";
} else if (fontFamily === "Roboto Mono") { } else if (fontFamily === "Roboto Mono") {
fallback = "monospace"; fallback = "monospace";
} }
fontCss = `
selector = "body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th"; body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
fontCss = "font-family: '" + fontFamily + "', " + fallback + " !important;"; font-family: '${fontFamily}', ${fallback} !important;
}
`;
} }
// --- ALIGNMENT LOGIC ---
var alignCss = ""; var alignCss = "";
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6"; var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
if (textAlign === "left") { if (textAlign === "left") {
alignCss = alignCss = alignSelector + " { text-align: left !important; }";
` ` +
alignSelector +
` {
text-align: left !important;
}
`;
} else if (textAlign === "justify") { } else if (textAlign === "justify") {
alignCss = alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
` ` +
alignSelector +
` {
text-align: justify !important;
-webkit-hyphens: auto !important;
hyphens: auto !important;
}
`;
} }
// --- GAP LOGIC --- var gapCss = "";
var gapCss = ` if (newGap !== 1.0) {
gapCss = `
body p, body ul, body ol, body blockquote { body p, body ul, body ol, body blockquote {
margin-top: ` + (0.5 * newGap) + `em !important; margin-top: ${newGap}em !important;
margin-bottom: ` + (0.5 * newGap) + `em !important; margin-bottom: ${newGap}em !important;
} }
body li { body li {
margin-bottom: ` + (0.25 * newGap) + `em !important; margin-bottom: ${0.5 * newGap}em !important;
} }
`; `;
}
dynamicStyleElement.innerHTML = var sizeCss = "";
` body { if (newFontSize !== 1.0) {
font-size: ` + sizeCss = `body { font-size: ${newFontSize}em !important; }`;
newFontSize + }
`em !important;
line-height: ` + var lineHeightCss = "";
newLineHeight + if (newLineHeight !== 1.0) {
` !important; lineHeightCss = `
body, p, div, span, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
line-height: ${newLineHeight} !important;
} }
`;
}
` + dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n");
selector +
` {
` +
fontCss +
`
}
` +
alignCss +
gapCss;
setTimeout( setTimeout(
function () { function () {
@ -565,29 +542,20 @@
console.log(logTag + ": [BODY] Computed font-family: " + computedBody); console.log(logTag + ": [BODY] Computed font-family: " + computedBody);
var firstPara = document.querySelector("p"); var firstPara = document.querySelector("p");
if (firstPara) { if (firstPara) {
var computedPara = window.getComputedStyle(firstPara).fontFamily; var computedPara = window.getComputedStyle(firstPara).fontFamily;
var computedAlign = window.getComputedStyle(firstPara).textAlign; console.log(logTag + ":[PARAGRAPH] Computed font-family: " + computedPara);
console.log(logTag + ": [PARAGRAPH] Computed font-family: " + computedPara);
console.log(logTag + ": [PARAGRAPH] Inner Text Sample: " + firstPara.innerText.substring(0, 20));
} else {
console.log(logTag + ": [PARAGRAPH] No <p> tag found to check.");
} }
if (fontFamily && fontFamily !== "") { if (fontFamily && fontFamily !== "") {
var isCheckAvailable = document.fonts && document.fonts.check; var isCheckAvailable = document.fonts && document.fonts.check;
if (isCheckAvailable) { if (isCheckAvailable) {
var loaded = document.fonts.check("12px '" + fontFamily + "'"); var loaded = document.fonts.check("12px '" + fontFamily + "'");
console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded); console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded);
} else {
console.log(logTag + ": document.fonts API not available.");
} }
} }
}, },
300
300,
); );
if (window.triggerInitialScrollStateReport) { if (window.triggerInitialScrollStateReport) {

View file

@ -396,6 +396,8 @@ class FolderSyncWorker(
return when { return when {
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB 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 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(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2 lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2

View file

@ -23,6 +23,7 @@ class MetadataExtractionWorker(
private val epubParser = EpubParser(appContext) private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext) private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext) private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
companion object { companion object {
const val WORK_NAME = "MetadataExtractionWorker" const val WORK_NAME = "MetadataExtractionWorker"
@ -109,6 +110,18 @@ class MetadataExtractionWorker(
} }
title = item.displayName 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 -> { else -> {
title = item.displayName title = item.displayName
} }

View file

@ -24,17 +24,12 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.os.Build import android.os.Build
import timber.log.Timber import android.speech.tts.TextToSpeech
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize 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.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.MoreVert
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow 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.Remove
import androidx.compose.material.icons.filled.Search 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.SwapHoriz
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -99,11 +100,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -130,11 +137,13 @@ import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton import com.aryan.reader.TooltipIconButton
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class ReaderTool(val title: String, val category: String) { enum class ReaderTool(val title: String, val category: String) {
@ -504,6 +513,8 @@ fun EpubReaderBottomBar(
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
ttsState: TtsState, ttsState: TtsState,
isProUser: Boolean, isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
onOpenTtsControls: () -> Unit,
onOpenSlider: () -> Unit, onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit, onToggleFormat: () -> Unit,
@ -658,6 +669,19 @@ fun EpubReaderBottomBar(
) else stringResource(R.string.content_desc_resume_tts) ) 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 showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) }
@ -3722,6 +3723,8 @@ fun EpubReaderHost(
ttsState = ttsState, ttsState = ttsState,
isProUser = isProUser, isProUser = isProUser,
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
currentTtsMode = currentTtsMode,
onOpenTtsControls = { showTtsControlsSheet = true },
onOpenSlider = { onOpenSlider = {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
@ -4176,6 +4179,14 @@ fun EpubReaderHost(
) )
} }
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
)
}
if (showCustomizeToolsSheet) { if (showCustomizeToolsSheet) {
CustomizeToolsSheet( CustomizeToolsSheet(
hiddenTools = hiddenTools, 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" private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f 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 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) { enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
ORIGINAL("original", "Original", "Original"), ORIGINAL("original", "Original", "Original"),
@ -559,7 +581,7 @@ fun ReaderTextFormatPanel(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Size // Size
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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( Slider(
value = currentFontSize, value = currentFontSize,
onValueChange = onFontSizeChange, onValueChange = onFontSizeChange,
@ -567,31 +589,31 @@ fun ReaderTextFormatPanel(
steps = 24, steps = 24,
modifier = Modifier.weight(1f) 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 // Lines
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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( Slider(
value = currentLineHeight, value = currentLineHeight,
onValueChange = onLineHeightChange, onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f, valueRange = 1.0f..3.0f,
steps = 14, steps = 19,
modifier = Modifier.weight(1f) 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 // Paragraph Gap
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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( Slider(
value = currentParagraphGap, value = currentParagraphGap,
onValueChange = onParagraphGapChange, onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f, valueRange = 0.0f..3.0f,
steps = 12, steps = 29,
modifier = Modifier.weight(1f) 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)) Spacer(Modifier.height(8.dp))

View file

@ -1145,6 +1145,26 @@ private fun getHighlightOffsetsInBlock(
return null 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 @Composable
private fun TextWithEmphasis( private fun TextWithEmphasis(
text: AnnotatedString, text: AnnotatedString,
@ -1577,13 +1597,22 @@ internal fun PaginatedReaderContent(
} }
val textBlocksOnPage = val textBlocksOnPage =
pageContent?.content?.filterIsInstance<TextContentBlock>() pageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList() ?.filter { it.cfi != null } ?: emptyList()
val lastTextBlock = textBlocksOnPage.lastOrNull() 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) { 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 (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) {
if (crossPageTriggerInfo?.first != pageIndex) { if (crossPageTriggerInfo?.first != pageIndex) {
Timber.tag("TextSelectionDiag") Timber.tag("TextSelectionDiag")
@ -1607,7 +1636,7 @@ internal fun PaginatedReaderContent(
val content = pageContent ?: return@LaunchedEffect val content = pageContent ?: return@LaunchedEffect
val firstTextBlock = val firstTextBlock =
content.content.filterIsInstance<TextContentBlock>() content.content.extractTextBlocks()
.firstOrNull { it.cfi != null } ?: run { .firstOrNull { it.cfi != null } ?: run {
pendingCrossPageSelection = null pendingCrossPageSelection = null
return@LaunchedEffect 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.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch 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 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 { enum class LinkSource {
ANNOTATION, TEXT_CONTENT ANNOTATION, TEXT_CONTENT
@ -371,6 +374,7 @@ data class PageSelectionData(
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>> val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
) )
@OptIn(FlowPreview::class)
@Suppress("unused") @Suppress("unused")
@Composable @Composable
internal fun PdfPageComposable( internal fun PdfPageComposable(
@ -1104,12 +1108,15 @@ internal fun PdfPageComposable(
try { try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) } page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect -> snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect ->
delay(150)
val tileCalcStart = System.nanoTime() val tileCalcStart = System.nanoTime()
if (!isActive) return@collect if (!isActive) return@collectLatest
if (isScrolling && effectiveScale > 1f) { if (isScrolling && effectiveScale > 1f) {
return@collect return@collectLatest
} }
val pxTl: Float val pxTl: Float
@ -1131,20 +1138,16 @@ internal fun PdfPageComposable(
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) } oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
} }
} }
return@collect return@collectLatest
} }
} else { } else {
val pivotX = screenWidth / 2f val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f val pivotY = screenHeight / 2f
pxTl = pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
(((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pyTl = pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
(((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY pyBr = (((screenHeight - 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()) val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
@ -1154,13 +1157,11 @@ internal fun PdfPageComposable(
val requiredTileIds = mutableSetOf<Int>() val requiredTileIds = mutableSetOf<Int>()
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0) val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
val endCol = val endCol = ((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0) val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
val endRow = val endRow = ((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost( (actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx )
)
for (row in startRow until endRow) { for (row in startRow until endRow) {
for (col in startCol until endCol) { for (col in startCol until endCol) {
@ -1170,6 +1171,9 @@ internal fun PdfPageComposable(
val currentTileIds = tiles.map { it.tileId }.toSet() 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 val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
if (duration > 2f) { if (duration > 2f) {
Timber.tag("PdfPerformance").d( 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 val tilesToRecycleIds = currentTileIds - requiredTileIds
if (tilesToRecycleIds.isNotEmpty()) { if (tilesToRecycleIds.isNotEmpty()) {
@ -1205,15 +1209,12 @@ internal fun PdfPageComposable(
(col + 1) * tileSizePx, (col + 1) * tileSizePx,
(row + 1) * tileSizePx (row + 1) * tileSizePx
) )
val tileRenderSize = val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
(tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
val tileBitmap = PdfBitmapPool.get(tileRenderSize) val tileBitmap = PdfBitmapPool.get(tileRenderSize)
val fullPageRenderWidth = val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
(actualBitmapWidthPx * effectiveScale).toInt() val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
val fullPageRenderHeight =
(actualBitmapHeightPx * effectiveScale).toInt()
val tileRenderX = (col * tileSizePx * effectiveScale).toInt() val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt() val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
@ -1226,12 +1227,19 @@ internal fun PdfPageComposable(
renderAnnot = true renderAnnot = true
) )
val newTile = PdfTile(tileBitmap, tileRect, tileId) val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
var handedOver = false var handedOver = false
try { try {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
tiles = tiles + newTile val oldTile = tiles.find { it.tileId == tileId }
tiles = tiles.filter { it.tileId != tileId } + newTile
handedOver = true handedOver = true
oldTile?.let {
coroutineScope.launch(Dispatchers.IO) {
PdfBitmapPool.recycle(it.bitmap)
}
}
} }
} finally { } finally {
if (!handedOver) { if (!handedOver) {
@ -2746,6 +2754,7 @@ internal fun PdfPageComposable(
} else if (mode == 2 && pointerCount > 1) { } else if (mode == 2 && pointerCount > 1) {
val oldScale = scale val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(1f, 4f) 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) val previousCentroid = event.calculateCentroid(useCurrent = false)
if (previousCentroid != Offset.Unspecified) { if (previousCentroid != Offset.Unspecified) {
@ -3328,8 +3337,12 @@ internal fun PdfPageComposable(
currentPageRotation = 0 currentPageRotation = 0
val MAX_BASE_DIMEN = 3000 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) { if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1) baseW = (baseW * downScale).toInt().coerceAtLeast(1)
@ -3394,8 +3407,12 @@ internal fun PdfPageComposable(
} }
val MAX_BASE_DIMEN = 3000 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) { if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH) val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1) baseW = (baseW * downScale).toInt().coerceAtLeast(1)

View file

@ -32,6 +32,8 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings 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.Bitmap
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri 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.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement 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.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.TtsControlsSheet
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
@ -1322,6 +1326,7 @@ fun PdfViewerScreen(
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) } var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
DisposableEffect(isKeepScreenOn) { DisposableEffect(isKeepScreenOn) {
@ -6322,12 +6327,15 @@ fun PdfViewerScreen(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp tonalElevation = 4.dp
) { ) {
val bottomBarScrollState = rememberScrollState()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp)
.horizontalScroll(bottomBarScrollState),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Slider Navigation Trigger // Slider Navigation Trigger
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) { if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
@ -6498,58 +6506,73 @@ fun PdfViewerScreen(
// TTS // TTS
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) { if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton( Box {
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) Row(verticalAlignment = Alignment.CenterVertically) {
else stringResource(R.string.tooltip_tts_start), TooltipIconButton(
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start_desc), else stringResource(R.string.tooltip_tts_start),
onClick = { description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
if (isTtsSessionActive) { else stringResource(R.string.tooltip_tts_start_desc),
Timber.d("TTS button clicked: Stopping TTS") onClick = {
ttsController.stop() if (isTtsSessionActive) {
} else { Timber.d("TTS button clicked: Stopping TTS")
startTtsWithPermissionCheck(null, null) 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) {
if (isTtsSessionActive) { TooltipIconButton(
TooltipIconButton( text = if (ttsState.isPlaying)
text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
stringResource(R.string.tooltip_tts_pause) else
else stringResource(R.string.tooltip_tts_resume),
stringResource(R.string.tooltip_tts_resume), description = if (ttsState.isPlaying)
description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
stringResource(R.string.tooltip_tts_pause_desc) else
else stringResource(R.string.tooltip_tts_resume_desc),
stringResource(R.string.tooltip_tts_resume_desc), onClick = {
onClick = { if (ttsState.isPlaying) {
if (ttsState.isPlaying) { ttsController.pause()
ttsController.pause() } else {
} else { ttsController.resume()
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 // Error Message Area
@ -7458,6 +7481,14 @@ fun PdfViewerScreen(
) )
} }
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
)
}
if (showDictionarySettingsSheet) { if (showDictionarySettingsSheet) {
DictionarySettingsDialog( DictionarySettingsDialog(
isVisible = true, isVisible = true,

View file

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

View file

@ -52,8 +52,10 @@ import kotlinx.coroutines.delay
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY) 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 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 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) 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 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_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS" const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@ -137,6 +139,8 @@ class TtsPlaybackManager(
.add(STOP_TTS_COMMAND) .add(STOP_TTS_COMMAND)
.add(CHANGE_SPEAKER_COMMAND) .add(CHANGE_SPEAKER_COMMAND)
.add(CHANGE_TTS_MODE_COMMAND) .add(CHANGE_TTS_MODE_COMMAND)
.add(FLUSH_PREFETCH_COMMAND)
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
.build() .build()
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@ -203,10 +207,71 @@ class TtsPlaybackManager(
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD } val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
handleChangeTtsMode(newMode) 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)) 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) { private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return if (currentTtsMode == newMode) return
currentTtsMode = newMode currentTtsMode = newMode