From 17a0097d4a8a4ff76b91fc17ecb27fb09d6f4332 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 10 Apr 2026 20:23:39 +0530 Subject: [PATCH] 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) --- app/build.gradle.kts | 4 +- app/src/main/assets/epub_reader.js | 94 +++---- .../java/com/aryan/reader/FolderSyncWorker.kt | 2 + .../aryan/reader/MetadataExtractionWorker.kt | 13 + .../reader/epubreader/EpubReaderControls.kt | 230 +++++++++++++++++- .../reader/epubreader/EpubReaderScreen.kt | 11 + .../reader/epubreader/EpubReaderSettings.kt | 42 +++- .../reader/paginatedreader/PaginatedReader.kt | 37 ++- .../com/aryan/reader/pdf/PdfPageComposable.kt | 83 ++++--- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 131 ++++++---- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 5 + .../com/aryan/reader/tts/TtsController.kt | 10 + .../aryan/reader/tts/TtsPlaybackManager.kt | 65 +++++ 13 files changed, 559 insertions(+), 168 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b10513d..48ee2fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 42 - versionName = "1.0.41" + versionCode = 43 + versionName = "1.0.42" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index fd7071a..0cdc0a2 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -481,83 +481,60 @@ var newGap = parseFloat(paragraphGap); 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; var fontCss = ""; - var selector = "body"; - if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { var fallback = "sans-serif"; - if (fontFamily === "Merriweather" || fontFamily === "Lora") { fallback = "serif"; } else if (fontFamily === "Roboto Mono") { fallback = "monospace"; } - - selector = "body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th"; - fontCss = "font-family: '" + fontFamily + "', " + fallback + " !important;"; + fontCss = ` + body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th { + font-family: '${fontFamily}', ${fallback} !important; + } + `; } - // --- ALIGNMENT LOGIC --- var alignCss = ""; var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6"; - if (textAlign === "left") { - alignCss = - ` ` + - alignSelector + - ` { - text-align: left !important; - } - - `; + alignCss = alignSelector + " { text-align: left !important; }"; } else if (textAlign === "justify") { - alignCss = - ` ` + - alignSelector + - ` { - text-align: justify !important; - -webkit-hyphens: auto !important; - hyphens: auto !important; - } - - `; + alignCss = 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 { - margin-top: ` + (0.5 * newGap) + `em !important; - margin-bottom: ` + (0.5 * newGap) + `em !important; + margin-top: ${newGap}em !important; + margin-bottom: ${newGap}em !important; } body li { - margin-bottom: ` + (0.25 * newGap) + `em !important; + margin-bottom: ${0.5 * newGap}em !important; } - `; + `; + } - dynamicStyleElement.innerHTML = - ` body { - font-size: ` + - newFontSize + - `em !important; - line-height: ` + - newLineHeight + - ` !important; + var sizeCss = ""; + if (newFontSize !== 1.0) { + sizeCss = `body { font-size: ${newFontSize}em !important; }`; + } + + var lineHeightCss = ""; + if (newLineHeight !== 1.0) { + lineHeightCss = ` + body, p, div, span, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th { + line-height: ${newLineHeight} !important; } + `; + } - ` + - selector + - ` { - ` + - fontCss + - ` - } - - ` + - alignCss + - gapCss; + dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n"); setTimeout( function () { @@ -565,29 +542,20 @@ console.log(logTag + ": [BODY] Computed font-family: " + computedBody); var firstPara = document.querySelector("p"); - if (firstPara) { var computedPara = window.getComputedStyle(firstPara).fontFamily; - var computedAlign = window.getComputedStyle(firstPara).textAlign; - 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

tag found to check."); + console.log(logTag + ":[PARAGRAPH] Computed font-family: " + computedPara); } if (fontFamily && fontFamily !== "") { var isCheckAvailable = document.fonts && document.fonts.check; - if (isCheckAvailable) { var loaded = document.fonts.check("12px '" + fontFamily + "'"); 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) { diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index d656f07..b1b304e 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -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 diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index f663779..8caee9c 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -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 } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 51c6864..7acead9 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -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(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") + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index b1a9cfb..de7cbe0 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -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, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index aa18c24..4cadb06 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -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)) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index a17bcae..26cd25e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -1145,6 +1145,26 @@ private fun getHighlightOffsetsInBlock( return null } +private fun List.extractTextBlocks(): List { + val result = mutableListOf() + 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() + 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() + content.content.extractTextBlocks() .firstOrNull { it.cfi != null } ?: run { pendingCrossPageSelection = null return@LaunchedEffect diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index c14a97e..5eea909 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -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> ) +@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() 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) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 171aa31..bd36835 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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, diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index 3b3fcc5..bfbdb58 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -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) diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index 5c29ce9..b56afee 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -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() } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 38ae8ae..951d142 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -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