From 3dc18438010d80d9952342123e2dd3bc5bcb241f Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 4 Mar 2026 10:16:32 +0530 Subject: [PATCH] Tts upgrade (#25) * enable cloud tts but only in pro(flavor) debug mode for testing * Added a device voice settings sheet to allow users to select and preview system-level TTS voices. * Refactored `DeviceVoiceSettingsSheet` to improve voice selection and filtering, and updated `BaseTtsSynthesizer` for better language handling. --- app/build.gradle.kts | 1 + app/src/main/java/com/aryan/reader/Common.kt | 672 +++++++++++++++++- .../reader/epubreader/EpubReaderControls.kt | 30 + .../reader/epubreader/EpubReaderScreen.kt | 50 +- .../aryan/reader/epubreader/EpubReaderTts.kt | 29 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 98 ++- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 38 +- .../com/aryan/reader/tts/TtsController.kt | 30 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 68 +- .../java/com/aryan/reader/tts/TtsUtils.kt | 3 +- 10 files changed, 869 insertions(+), 150 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 31a4a1b..d78ee9a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,7 @@ android { buildConfigField("String", "VERIFIER_WORKER_URL", "\"\"") buildConfigField("String", "FEEDBACK_WORKER_URL", "\"\"") buildConfigField("boolean", "IS_PRO", "false") + buildConfigField("String", "TTS_WORKER_URL", "\"\"") } } diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 32accdc..a037698 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -20,14 +20,20 @@ package com.aryan.reader import android.content.Context -import timber.log.Timber +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import android.speech.tts.TextToSpeech +import android.speech.tts.Voice import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -35,9 +41,14 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -45,71 +56,80 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.media3.common.util.UnstableApi +import com.aryan.reader.paginatedreader.TtsChunk +import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS +import com.aryan.reader.tts.SpeakerSamplePlayer +import com.aryan.reader.tts.TtsPlaybackManager +import com.aryan.reader.tts.loadTtsMode +import com.aryan.reader.tts.rememberTtsController +import com.aryan.reader.tts.splitTextIntoChunks import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL -import androidx.annotation.OptIn -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Stop -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.media3.common.util.UnstableApi -import com.aryan.reader.paginatedreader.TtsChunk -import com.aryan.reader.tts.rememberTtsController -import com.aryan.reader.tts.splitTextIntoChunks -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle import org.commonmark.node.AbstractVisitor import org.commonmark.node.Code import org.commonmark.node.Emphasis @@ -121,8 +141,12 @@ import org.commonmark.node.SoftLineBreak import org.commonmark.node.StrongEmphasis import org.commonmark.node.Text import org.commonmark.parser.Parser +import org.json.JSONObject +import timber.log.Timber import java.io.File -import androidx.compose.runtime.DisposableEffect +import java.net.HttpURLConnection +import java.net.URL +import androidx.core.content.edit const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val summarizeEndpoint = "/summarize" @@ -132,6 +156,8 @@ const val aiDefinitionUrl = aiServerBasePath + defineEndpoint const val recapEndpoint = "/recap" const val recapUrl = aiServerBasePath + recapEndpoint +const val PREF_NATIVE_TTS_VOICE = "native_tts_voice_name" + data class SearchResult( val locationInSource: Int, val locationTitle: String, @@ -340,6 +366,7 @@ fun SummarizationPopup( ) { val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() + val context = LocalContext.current LocalContext.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() @@ -419,7 +446,7 @@ fun SummarizationPopup( bookTitle = title, chapterTitle = "Summary", coverImageUri = null, - ttsMode = "BASE", + ttsMode = loadTtsMode(context), playbackSource = "POPUP" ) } @@ -508,7 +535,7 @@ fun AiDefinitionPopup( ) { val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() - LocalContext.current + val context = LocalContext.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() @@ -593,7 +620,7 @@ fun AiDefinitionPopup( bookTitle = "AI Definition", chapterTitle = word, coverImageUri = null, - ttsMode = "BASE", + ttsMode = loadTtsMode(context), playbackSource = "POPUP" ) } @@ -981,4 +1008,591 @@ suspend fun fetchRecap( onFinish() } } +} + +@OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TtsSettingsSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + currentMode: TtsPlaybackManager.TtsMode, + onModeChange: (TtsPlaybackManager.TtsMode) -> Unit, + currentSpeakerId: String, + onSpeakerChange: (String) -> Unit, + isTtsActive: Boolean +) { + if (isVisible) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + rememberLazyListState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val samplePlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) } + + DisposableEffect(Unit) { + onDispose { samplePlayer.release() } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + Text( + text = "Text-to-Speech Settings", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (isTtsActive) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp) + ) { + Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer) + Spacer(Modifier.width(12.dp)) + Text( + "Please stop playback to change settings.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + Text( + text = "Synthesis Mode", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Mode Selector + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(25.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + TtsPlaybackManager.TtsMode.entries.forEach { mode -> + val isSelected = currentMode == mode + val label = if (mode == TtsPlaybackManager.TtsMode.BASE) "On-Device" else "Cloud (HQ)" + val icon = if (mode == TtsPlaybackManager.TtsMode.BASE) Icons.Default.Smartphone else Icons.Default.Cloud + + Surface( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable(enabled = !isTtsActive) { onModeChange(mode) }, + shape = RoundedCornerShape(25.dp), + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + } + } + } + } + + Spacer(Modifier.height(24.dp)) + + if (currentMode == TtsPlaybackManager.TtsMode.CLOUD) { + Text( + text = "Voice Selection", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) + ) { + items(GOOGLE_TTS_SPEAKERS) { (name, id) -> + val isSelected = currentSpeakerId == id + val isPlaying = samplePlayer.playingSpeakerId == id + val isLoading = samplePlayer.loadingSpeakerId == id + + ListItem( + headlineContent = { Text(name, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + leadingContent = { + if (isSelected) { + Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary) + } else { + Icon(Icons.Default.GraphicEq, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) + } + }, + trailingContent = { + if (!isTtsActive) { + IconButton(onClick = { samplePlayer.playOrStop(id) }) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon( + imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = "Play Sample", + tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent + ), + modifier = Modifier.clickable(enabled = !isTtsActive) { + onSpeakerChange(id) + } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Using system default engine settings.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +fun loadNativeVoice(context: Context): String? { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getString(PREF_NATIVE_TTS_VOICE, null) +} + +private fun saveNativeVoice(context: Context, @Suppress("SameParameterValue") voiceName: String?) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + if (voiceName == null) { + prefs.edit { remove(PREF_NATIVE_TTS_VOICE) } + } else { + prefs.edit { putString(PREF_NATIVE_TTS_VOICE, voiceName) } + } +} + +@Composable +fun DeviceVoiceSettingsSheet( + isVisible: Boolean, + onDismiss: () -> Unit +) { + if (isVisible) { + val listState = rememberLazyListState() + val context = LocalContext.current + @Suppress("UnusedVariable", "Unused") val scope = rememberCoroutineScope() + + var ttsEngine by remember { mutableStateOf(null) } + var allVoices by remember { mutableStateOf>(emptyList()) } + var isTtsLoading by remember { mutableStateOf(true) } + + var savedVoiceName by remember { mutableStateOf(loadNativeVoice(context)) } + + val allLanguagesOption = "All Languages" + var selectedLanguage by remember { mutableStateOf(allLanguagesOption) } + + val numberedVoiceNames = remember(allVoices) { + val nameMap = mutableMapOf() + + val groupedByLanguage = allVoices.groupBy { it.locale.displayName } + + groupedByLanguage.forEach { (langName, voiceList) -> + if (voiceList.size > 1) { + voiceList.forEachIndexed { index, voice -> + val type = try { + if (voice.isNetworkConnectionRequired) "Online" else "Offline" + } catch (_: Exception) { + "Offline" + } + + nameMap[voice.name] = "$langName ($type) - ${index + 1}" + } + } else { + val voice = voiceList[0] + val type = try { + if (voice.isNetworkConnectionRequired) "Online" else "Offline" + } catch (_: Exception) { + "Offline" + } + + nameMap[voice.name] = "$langName ($type)" + } + } + nameMap + } + + DisposableEffect(Unit) { + var tts: TextToSpeech? = null + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + try { + val enginesVoices = tts?.voices + if (enginesVoices != null) { + allVoices = enginesVoices.toList().sortedBy { it.locale.displayName } + } + } catch (e: Exception) { + Timber.e(e, "Failed to fetch voices") + } finally { + isTtsLoading = false + } + } else { + isTtsLoading = false + Timber.e("TTS Initialization failed with status $status") + } + } + ttsEngine = tts + onDispose { + tts.shutdown() + } + } + + val availableLanguages = remember(allVoices) { + val languages = + allVoices.asSequence().map { it.locale.displayLanguage }.filter { it.isNotBlank() } + .distinct().sorted().toMutableList() + + languages.add(0, allLanguagesOption) + languages + } + + LaunchedEffect(allVoices, savedVoiceName) { + if (savedVoiceName != null && allVoices.isNotEmpty()) { + val savedVoice = allVoices.find { it.name == savedVoiceName } + if (savedVoice != null) { + val voiceLanguage = savedVoice.locale.displayLanguage + if (selectedLanguage == allLanguagesOption) { + selectedLanguage = voiceLanguage + } + } + } + } + + val filteredVoices = remember(selectedLanguage, allVoices) { + if (selectedLanguage == allLanguagesOption) { + allVoices + } else { + allVoices.filter { it.locale.displayLanguage == selectedLanguage } + } + } + + LaunchedEffect(filteredVoices, savedVoiceName) { + if (savedVoiceName != null && filteredVoices.isNotEmpty()) { + val index = filteredVoices.indexOfFirst { it.name == savedVoiceName } + if (index != -1) { + Timber.d("Auto-scrolling to voice at index: $index") + delay(300) + listState.animateScrollToItem(index) + } + } + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( + usePlatformDefaultWidth = false + ) + ) { + Column( + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom + ) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth().clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + + Surface( + modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier.fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 24.dp).padding(bottom = 24.dp, top = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "On-Device Voice Settings", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close Settings") + } + } + + Surface( + color = if (savedVoiceName == null) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + tonalElevation = if (savedVoiceName == null) 4.dp else 0.dp, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).clickable { + savedVoiceName = null + saveNativeVoice(context, null) + Timber.d("Native TTS: Reset to System Default") + }) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Smartphone, + contentDescription = null, + tint = if (savedVoiceName == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "System Default", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Matches your Android system settings", + style = MaterialTheme.typography.bodySmall + ) + } + if (savedVoiceName == null) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) + + if (isTtsLoading) { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading voices...", modifier = Modifier.padding(top = 48.dp)) + } + } else if (allVoices.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(100.dp), + contentAlignment = Alignment.Center + ) { + Text( + "No voices available on this device.", + color = MaterialTheme.colorScheme.error + ) + } + } else { + var expandedLanguageMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Specific Voices", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Surface( + modifier = Modifier.fillMaxWidth().height(50.dp) + .clickable { expandedLanguageMenu = true }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke( + 1.dp, MaterialTheme.colorScheme.outlineVariant + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = selectedLanguage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + DropdownMenu( + expanded = expandedLanguageMenu, + onDismissRequest = { expandedLanguageMenu = false }, + modifier = Modifier.fillMaxWidth(0.85f).heightIn(max = 400.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + availableLanguages.forEach { language -> + DropdownMenuItem(text = { + Text( + text = language, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, onClick = { + selectedLanguage = language + expandedLanguageMenu = false + }) + } + } + } + + if (filteredVoices.isNotEmpty()) { + Text( + text = "Available Voices (${filteredVoices.size})", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) + ) + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f, fill = false) + .padding(vertical = 4.dp).border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(12.dp) + ) + ) { + items( + filteredVoices.size, + key = { filteredVoices[it].name }) { index -> + val voice = filteredVoices[index] + val isSelected = voice.name == savedVoiceName + val friendlyName = numberedVoiceNames[voice.name] + ?: voice.locale.displayName + + ListItem( + headlineContent = { + Text( + text = friendlyName, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface + ) + }, + supportingContent = if (voice.locale.variant.isNotEmpty()) { + { Text("Variant: ${voice.locale.variant}") } + } else null, + leadingContent = { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + }, + trailingContent = { + IconButton(onClick = { + val params = android.os.Bundle() + ttsEngine?.voice = voice + val sampleText = + "This is a sample of ${voice.locale.displayLanguage}." + ttsEngine?.speak( + sampleText, + TextToSpeech.QUEUE_FLUSH, + params, + "SAMPLE_ID" + ) + }) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Play Sample", + tint = MaterialTheme.colorScheme.primary + ) + } + }, + modifier = Modifier.clickable { + savedVoiceName = voice.name + saveNativeVoice(context, voice.name) + }.background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy( + alpha = 0.2f + ) else Color.Transparent + ) + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ) + ) + } + } + } else { + Box( + modifier = Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text( + "No voices found for this language.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + } } \ No newline at end of file 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 8f6c9e8..a5b97dd 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu @@ -145,6 +146,8 @@ fun EpubReaderTopBar( onToggleVolumeScroll: (Boolean) -> Unit, onTogglePageTurnAnimation: (Boolean) -> Unit, onStartAutoScroll: () -> Unit, + onOpenTtsSettings: () -> Unit, + onOpenDeviceVoiceSettings: () -> Unit, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, modifier: Modifier = Modifier ) { @@ -268,6 +271,33 @@ fun EpubReaderTopBar( onStartAutoScroll() } ) + + HorizontalDivider() + + // *** ADDITION START *** + DropdownMenuItem( + text = { Text("TTS Voice Settings") }, + onClick = { + showMoreMenu = false + onOpenDeviceVoiceSettings() + }, + leadingIcon = { + Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("TTS Settings (Debug)") }, + onClick = { + showMoreMenu = false + onOpenTtsSettings() + }, + leadingIcon = { + Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + } } } } 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 15a5653..4bb56d5 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -127,10 +127,12 @@ import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BannerMessage import com.aryan.reader.BuildConfig import com.aryan.reader.CustomTopBanner +import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.RenderMode import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager +import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.countWords import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.epub.EpubBook @@ -150,7 +152,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.semanticBlockModule import com.aryan.reader.rememberSearchState import com.aryan.reader.tts.SpeakerSamplePlayer -import com.aryan.reader.tts.TtsPlaybackManager.TtsMode +import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks import kotlinx.coroutines.Job @@ -177,6 +179,7 @@ private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider" private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed" private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed" private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled" +private const val TTS_MODE_KEY = "tts_mode" private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -229,6 +232,11 @@ private fun loadAutoScrollUseSlider(context: Context): Boolean { return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false) } +private fun saveTtsMode(context: Context, modeName: String) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putString(TTS_MODE_KEY, modeName) } +} + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun EpubReaderScreen( @@ -325,6 +333,8 @@ fun EpubReaderHost( mutableStateOf(loadPageTurnAnimationSetting(context)) } + var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } + val locatorConverter = remember(context) { LocatorConverter( bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(), @@ -689,7 +699,7 @@ fun EpubReaderHost( bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, - ttsMode = TtsMode.BASE.name, + ttsMode = currentTtsMode, playbackSource = "READER" ) } @@ -708,6 +718,8 @@ fun EpubReaderHost( var showPermissionRationaleDialog by remember { mutableStateOf(false) } val isDarkTheme = isSystemInDarkTheme() + var showTtsSettingsSheet by remember { mutableStateOf(false) } + var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } TtsSessionObserver( ttsState = ttsState, @@ -731,7 +743,8 @@ fun EpubReaderHost( }, onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, userStoppedTts = userStoppedTts, - scope = scope + scope = scope, + currentTtsMode = currentTtsMode ) TtsHighlightHandler( @@ -1917,7 +1930,7 @@ fun EpubReaderHost( bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, - ttsMode = TtsMode.BASE.name, + ttsMode = currentTtsMode, playbackSource = "READER" ) } else { @@ -2741,7 +2754,9 @@ fun EpubReaderHost( showBars = true }, searchFocusRequester = searchFocusRequester, - modifier = Modifier.align(Alignment.TopCenter) + modifier = Modifier.align(Alignment.TopCenter), + onOpenTtsSettings = { showTtsSettingsSheet = true }, + onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true } ) val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( @@ -3206,6 +3221,31 @@ fun EpubReaderHost( PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) } + if (showTtsSettingsSheet) { + TtsSettingsSheet( + isVisible = true, + onDismiss = { showTtsSettingsSheet = false }, + currentMode = currentTtsMode, + onModeChange = { newMode -> + currentTtsMode = newMode + saveTtsMode(context, newMode.name) + ttsController.changeTtsMode(newMode.name) + }, + currentSpeakerId = ttsState.speakerId, + onSpeakerChange = { newSpeaker -> + ttsController.changeSpeaker(newSpeaker) + }, + isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER" + ) + } + + if (showDeviceVoiceSettingsSheet) { + DeviceVoiceSettingsSheet( + isVisible = true, + onDismiss = { showDeviceVoiceSettingsSheet = false } + ) + } + if (showFontSelectionSheet) { ModalBottomSheet( onDismissRequest = { showFontSelectionSheet = false }, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index 8a126cc..b9e8a00 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File -const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS" private const val TTS_MODE_KEY = "tts_mode" data class TtsHighlightInfo( @@ -61,13 +60,6 @@ fun saveTtsMode(context: Context, mode: TtsMode) { prefs.edit { putString(TTS_MODE_KEY, mode.name) } } -@Suppress("unused") -@OptIn(UnstableApi::class) -fun loadTtsMode(): TtsMode { - // For this release, Cloud TTS is disabled. Force BASE mode. - return TtsMode.BASE -} - /** * Helper to update the WebView auto-scroll state. */ @@ -121,20 +113,20 @@ fun TtsSessionObserver( onNavigateToChapter: (Int) -> Unit, onToggleTtsStartOnLoad: (Boolean) -> Unit, userStoppedTts: Boolean, - scope: CoroutineScope + scope: CoroutineScope, + currentTtsMode: TtsMode ) { val prevTtsState = remember { mutableStateOf(ttsState) } LaunchedEffect(ttsState) { val wasPlaying = prevTtsState.value.isPlaying val isPlaying = ttsState.isPlaying - val isChangingConfig = ttsState.isChangingConfig val sessionFinished = ttsState.sessionFinished val wasSessionFinished = prevTtsState.value.sessionFinished val sessionEndedByStop = ttsState.sessionEndedByStop val isReaderSource = ttsState.playbackSource == "READER" - if (!isChangingConfig && isReaderSource) { + if (isReaderSource) { if (sessionFinished && !wasSessionFinished) { Timber.d("TTS finished naturally. Checking for next content.") @@ -161,7 +153,8 @@ fun TtsSessionObserver( epubBookTitle = epubBookTitle, coverImagePath = coverImagePath, onUpdateTtsChapter = onTtsChapterIndexChange, - scope = scope + scope = scope, + ttsMode = currentTtsMode ) } } else if (wasPlaying && !isPlaying && !sessionFinished) { @@ -271,14 +264,14 @@ private fun handlePaginatedAutoAdvance( epubBookTitle: String, coverImagePath: String?, onUpdateTtsChapter: (Int?) -> Unit, - scope: CoroutineScope + scope: CoroutineScope, + ttsMode: TtsMode ) { - val lastPlayedChapter = currentTtsChapterIndex - if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) { + if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { Timber.d("Paginated: Searching for next TTS content...") scope.launch { - var chapterToTry = lastPlayedChapter + 1 + var chapterToTry = currentTtsChapterIndex + 1 var foundContent = false val bookPaginator = paginator as? BookPaginator @@ -288,7 +281,6 @@ private fun handlePaginatedAutoAdvance( } while (chapterToTry < chapters.size) { - // Visually scroll to start of chapter val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry] if (targetPage != null && pagerState.currentPage != targetPage) { pagerState.animateScrollToPage(targetPage) @@ -309,13 +301,12 @@ private fun handlePaginatedAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapterTitle, coverImageUri = coverUriString, - ttsMode = "BASE" // Defaulting to Base for safety + ttsMode = ttsMode ) foundContent = true break } else { Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.") - // Visually flip through empty pages if needed val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0 if (pageCount > 1) { for (i in 1 until pageCount) { 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 c10e6cf..b983052 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -26,12 +26,6 @@ import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.geometry.lerp -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.RectF @@ -99,6 +93,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -134,10 +129,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -174,13 +167,18 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -194,6 +192,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle @@ -224,13 +223,14 @@ import androidx.paging.compose.itemKey import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig +import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.MainViewModel import com.aryan.reader.R import com.aryan.reader.SearchResult import com.aryan.reader.SearchTopBar import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationResult -import com.aryan.reader.SyncUpdateInfo +import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.fetchAiDefinition import com.aryan.reader.paginatedreader.TtsChunk @@ -246,6 +246,8 @@ import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.rememberSearchState import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer +import com.aryan.reader.tts.TtsPlaybackManager +import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks import io.legere.pdfiumandroid.PdfDocument @@ -263,7 +265,6 @@ import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -473,11 +474,6 @@ private fun loadPdfDarkMode(context: Context): Boolean { return prefs.getBoolean(PDF_DARK_MODE_KEY, false) } -@Suppress("unused") -private enum class TtsMode { - CLOUD, BASE -} - private data class TtsPageData( val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean ) @@ -501,18 +497,13 @@ private fun flattenToc(bookmarks: List, level: Int = 0): L return entries } +@OptIn(UnstableApi::class) @Suppress("unused") -private fun saveTtsMode(context: Context, mode: TtsMode) { +private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) prefs.edit { putString(TTS_MODE_KEY, mode.name) } } -@Suppress("unused") -private fun loadTtsMode(): TtsMode { - // For this release, Cloud TTS is disabled. Force BASE mode. - return TtsMode.BASE -} - private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? { return withContext(Dispatchers.IO) { var page: PdfPageKt? = null @@ -658,6 +649,10 @@ fun PdfViewerScreen( var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } + var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } + var showTtsSettingsSheet by remember { mutableStateOf(false) } + + var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } fun triggerAutoScrollTempPause(durationMs: Long) { if (!isAutoScrollModeActive || !isAutoScrollPlaying) return @@ -1981,7 +1976,7 @@ fun PdfViewerScreen( bookTitle = bookTitle, chapterTitle = pageTitle, coverImageUri = null, - ttsMode = TtsMode.BASE.name, + ttsMode = currentTtsMode, playbackSource = "READER" ) @@ -2688,6 +2683,8 @@ fun PdfViewerScreen( searchState.onQueryChange("") } + showTtsSettingsSheet -> showTtsSettingsSheet = false + else -> { saveStateAndExit() } @@ -4137,6 +4134,36 @@ fun PdfViewerScreen( showBars = true } ) + + HorizontalDivider() + DropdownMenuItem( + text = { Text("TTS Voice Settings") }, + onClick = { + showMoreMenu = false + showDeviceVoiceSettingsSheet = true + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("TTS Settings (Debug)") }, + onClick = { + showMoreMenu = false + showTtsSettingsSheet = true + }, + leadingIcon = { + Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp)) + } + ) + } + HorizontalDivider() DropdownMenuItem(text = { Text( @@ -5410,6 +5437,31 @@ fun PdfViewerScreen( }) } + if (showTtsSettingsSheet) { + TtsSettingsSheet( + isVisible = true, + onDismiss = { showTtsSettingsSheet = false }, + currentMode = currentTtsMode, + onModeChange = { newMode -> + currentTtsMode = newMode + saveTtsMode(context, newMode) + ttsController.changeTtsMode(newMode.name) + }, + currentSpeakerId = ttsState.speakerId, + onSpeakerChange = { newSpeaker -> + ttsController.changeSpeaker(newSpeaker) + }, + isTtsActive = isTtsSessionActive + ) + } + + if (showDeviceVoiceSettingsSheet) { + DeviceVoiceSettingsSheet( + isVisible = true, + onDismiss = { showDeviceVoiceSettingsSheet = false } + ) + } + if (clickedLinkUrl != null) { val url = clickedLinkUrl!! AlertDialog( 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 4b60807..9fa6c7a 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,7 @@ import android.content.Context import android.os.Bundle import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener +import com.aryan.reader.loadNativeVoice import timber.log.Timber import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException @@ -102,7 +103,6 @@ class BaseTtsSynthesizer(private val context: Context) { if (isInitialized) return return suspendCancellableCoroutine { continuation -> Timber.d("BaseTts: Initializing TextToSpeech engine...") - // Use Application Context to prevent memory leaks and detachment issues tts = TextToSpeech(context.applicationContext) { status -> if (status == TextToSpeech.SUCCESS) { isInitialized = true @@ -137,11 +137,38 @@ class BaseTtsSynthesizer(private val context: Context) { } finally { tts = null isInitialized = false - // COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again. delay(350) } } + private fun applyPreferredVoice() { + if (tts == null) return + + try { + val preferredVoiceName = loadNativeVoice(context) ?: return + + if (tts?.voice?.name == preferredVoiceName) return + + val availableVoices = tts?.voices + if (availableVoices != null) { + val targetVoice = availableVoices.find { it.name == preferredVoiceName } + if (targetVoice != null) { + Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})") + tts?.voice = targetVoice + try { + tts?.language = targetVoice.locale + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to set language for voice") + } + } else { + Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.") + } + } + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to apply preferred voice") + } + } + suspend fun synthesizeToFile(text: String): Pair { if (text.isBlank()) { return Pair(null, text) @@ -154,7 +181,6 @@ class BaseTtsSynthesizer(private val context: Context) { val utteranceId = UUID.randomUUID().toString() val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir) - // Prepare signals val resultDeferred = CompletableDeferred>() val startSignal = CompletableDeferred() @@ -170,12 +196,12 @@ class BaseTtsSynthesizer(private val context: Context) { } } + applyPreferredVoice() + Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId") requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text) - // Prevention: No tts?.stop() here. - val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId) if (ttsResult == TextToSpeech.ERROR) { @@ -202,7 +228,7 @@ class BaseTtsSynthesizer(private val context: Context) { if (finalResult.first != null) { result = finalResult - break // Success! + break } else { Timber.w("BaseTts: onError received during processing.") throw IllegalStateException("TTS Engine reported onError") 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 77e31ce..5c29ce9 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -36,6 +36,7 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken +import com.aryan.reader.BuildConfig import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors @@ -63,6 +64,28 @@ private fun loadSpeaker(context: Context): String { return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID } +@OptIn(UnstableApi::class) +@Suppress("KotlinConstantConditions") +fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name) + ?: TtsPlaybackManager.TtsMode.BASE.name + + val isCloudAllowed = BuildConfig.DEBUG && + BuildConfig.IS_PRO && + BuildConfig.TTS_WORKER_URL.isNotBlank() + + return if (isCloudAllowed) { + try { + TtsPlaybackManager.TtsMode.valueOf(savedModeName) + } catch (_: Exception) { + TtsPlaybackManager.TtsMode.BASE + } + } else { + TtsPlaybackManager.TtsMode.BASE + } +} + @UnstableApi class TtsController(context: Context) : Player.Listener { @@ -135,7 +158,7 @@ class TtsController(context: Context) : Player.Listener { bookTitle: String, chapterTitle: String?, coverImageUri: String?, - ttsMode: String, + ttsMode: TtsPlaybackManager.TtsMode, playbackSource: String = "READER" ) { if (chunks.isEmpty()) { @@ -143,7 +166,6 @@ class TtsController(context: Context) : Player.Listener { return } Timber.d("UI sending START command with mode: $ttsMode") - Timber.d("TtsController: Sending START command. Chunk count: ${chunks.size}. Mode: $ttsMode. First chunk len: ${chunks.first().text.length}") val textList = ArrayList(chunks.map { it.text }) val cfiList = ArrayList(chunks.map { it.sourceCfi }) @@ -157,7 +179,7 @@ class TtsController(context: Context) : Player.Listener { putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_COVER_IMAGE_URI, coverImageUri) - putString(KEY_TTS_MODE, ttsMode) + putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_PLAYBACK_SOURCE, playbackSource) } mediaController?.sendCustomCommand(START_TTS_COMMAND, args) @@ -221,7 +243,6 @@ class TtsController(context: Context) : Player.Listener { val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId) val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false) val isLoading = customState.getBoolean("isLoading", false) - val isChangingConfig = customState.getBoolean("isChangingConfig", false) val sessionFinished = customState.getBoolean("sessionFinished", false) val playbackSource = customState.getString("playbackSource") @@ -256,7 +277,6 @@ class TtsController(context: Context) : Player.Listener { sessionEndedByStop = sessionEndedByStop, currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null, currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1, - isChangingConfig = isChangingConfig, sessionFinished = sessionFinished, playbackSource = playbackSource ) 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 f9439ea..6cc3a44 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -80,7 +80,6 @@ class TtsPlaybackManager( private val prefetchingJobs = mutableMapOf() private var wordTrackingJob: Job? = null private var preparationJob: Job? = null - private var isChangingConfig = false enum class TtsMode { CLOUD, BASE @@ -98,7 +97,6 @@ class TtsPlaybackManager( val sessionEndedByStop: Boolean = false, val currentWordSourceCfi: String? = null, val currentWordStartOffset: Int = -1, - val isChangingConfig: Boolean = false, val sessionFinished: Boolean = false, val playbackSource: String? = null ) @@ -211,29 +209,8 @@ class TtsPlaybackManager( private fun handleChangeTtsMode(newMode: TtsMode) { if (currentTtsMode == newMode) return - preparationJob?.cancel() - isChangingConfig = true - val wasPlaying = player.isPlaying - val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull() - val currentPosition = player.currentPosition currentTtsMode = newMode - if (textChunks.isEmpty()) { - _ttsState.value = _ttsState.value.copy(isPlaying = false) - isChangingConfig = false - return - } - _ttsState.value = _ttsState.value.copy(isLoading = true, isPlaying = false, isChangingConfig = true) - player.stop() - player.clearMediaItems() - prefetchingJobs.values.forEach { it.cancel() } - prefetchingJobs.clear() - scope.launch { - clearAudioFiles() - } - preparationJob = scope.launch { - val startIndex = currentChunkIndex ?: 0 - prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition) - } + Timber.d("TTS Mode changed to $newMode (pending next start)") } private fun handleStartTts( @@ -245,10 +222,6 @@ class TtsPlaybackManager( ttsMode: TtsMode, playbackSource: String? ) { - if (isChangingConfig) { - Timber.w("Ignoring START command because a config change is already in progress.") - return - } if (chunks.isEmpty()) { _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") return @@ -269,36 +242,15 @@ class TtsPlaybackManager( private fun handleChangeSpeaker(newSpeakerId: String) { if (currentSpeakerId == newSpeakerId) return - preparationJob?.cancel() - isChangingConfig = true - val wasPlaying = player.isPlaying - val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull() - val currentPosition = player.currentPosition currentSpeakerId = newSpeakerId - if (textChunks.isEmpty()) { - _ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId) - isChangingConfig = false - return - } - _ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId, isLoading = true, isPlaying = false, isChangingConfig = true) - player.stop() - player.clearMediaItems() - prefetchingJobs.values.forEach { it.cancel() } - prefetchingJobs.clear() - scope.launch { - clearAudioFiles() - } - preparationJob = scope.launch { - val startIndex = currentChunkIndex ?: 0 - prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition) - } + _ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId) + Timber.d("Speaker changed to $newSpeakerId (pending next start)") } private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) { val firstChunk = textChunks.getOrNull(startAtIndex) if (firstChunk == null) { - _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.", isChangingConfig = false) - withContext(Dispatchers.Main) { isChangingConfig = false } + _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.") return } @@ -323,20 +275,17 @@ class TtsPlaybackManager( player.seekTo(startAtPosition) } player.playWhenReady = playWhenReady - isChangingConfig = false _ttsState.value = _ttsState.value.copy( isLoading = false, isPlaying = playWhenReady, currentText = serverText, sourceCfi = updatedChunk.sourceCfi, - startOffsetInSource = updatedChunk.startOffsetInSource, - isChangingConfig = false + startOffsetInSource = updatedChunk.startOffsetInSource ) } prefetchNextChunkAudio(startAtIndex) } else { - withContext(Dispatchers.Main) { isChangingConfig = false } - _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false) + _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.") } } @@ -456,7 +405,6 @@ class TtsPlaybackManager( if (isLastChunkInSession || textChunks.isEmpty()) { nextState = nextState.copy(sessionFinished = true) } else { - // Check if prefetch is active for the next chunk val nextIdx = currentChunkIndex + 1 val isPrefetching = prefetchingJobs.containsKey(nextIdx) @@ -472,9 +420,6 @@ class TtsPlaybackManager( _ttsState.value = nextState if (!isPlaying && player.playbackState == Player.STATE_IDLE) { - if (isChangingConfig) { - return - } if (!nextState.sessionEndedByStop) { handleStopTts(userInitiated = true) } @@ -620,7 +565,6 @@ class TtsPlaybackManager( putBoolean("sessionEndedByStop", state.sessionEndedByStop) putString("currentWordSourceCfi", state.currentWordSourceCfi) putInt("currentWordStartOffset", state.currentWordStartOffset) - putBoolean("isChangingConfig", state.isChangingConfig) putBoolean("sessionFinished", state.sessionFinished) putString("playbackSource", state.playbackSource) } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt index 6846665..9dd3882 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toUri +import com.aryan.reader.BuildConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -34,7 +35,7 @@ import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL -const val googleCloudWorkerTtsUrl = "" +const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark."