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.
This commit is contained in:
Aryan 2026-03-04 10:16:32 +05:30 committed by GitHub
parent 1ce353ad44
commit 3dc1843801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 869 additions and 150 deletions

View file

@ -47,6 +47,7 @@ android {
buildConfigField("String", "VERIFIER_WORKER_URL", "\"\"") buildConfigField("String", "VERIFIER_WORKER_URL", "\"\"")
buildConfigField("String", "FEEDBACK_WORKER_URL", "\"\"") buildConfigField("String", "FEEDBACK_WORKER_URL", "\"\"")
buildConfigField("boolean", "IS_PRO", "false") buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("String", "TTS_WORKER_URL", "\"\"")
} }
} }

View file

@ -20,14 +20,20 @@
package com.aryan.reader package com.aryan.reader
import android.content.Context 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding 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.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp 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.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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color 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.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.AbstractVisitor
import org.commonmark.node.Code import org.commonmark.node.Code
import org.commonmark.node.Emphasis import org.commonmark.node.Emphasis
@ -121,8 +141,12 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text import org.commonmark.node.Text
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.json.JSONObject
import timber.log.Timber
import java.io.File 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 aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize" const val summarizeEndpoint = "/summarize"
@ -132,6 +156,8 @@ const val aiDefinitionUrl = aiServerBasePath + defineEndpoint
const val recapEndpoint = "/recap" const val recapEndpoint = "/recap"
const val recapUrl = aiServerBasePath + recapEndpoint const val recapUrl = aiServerBasePath + recapEndpoint
const val PREF_NATIVE_TTS_VOICE = "native_tts_voice_name"
data class SearchResult( data class SearchResult(
val locationInSource: Int, val locationInSource: Int,
val locationTitle: String, val locationTitle: String,
@ -340,6 +366,7 @@ fun SummarizationPopup(
) { ) {
val ttsController = rememberTtsController() val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState() val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current
LocalContext.current LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -419,7 +446,7 @@ fun SummarizationPopup(
bookTitle = title, bookTitle = title,
chapterTitle = "Summary", chapterTitle = "Summary",
coverImageUri = null, coverImageUri = null,
ttsMode = "BASE", ttsMode = loadTtsMode(context),
playbackSource = "POPUP" playbackSource = "POPUP"
) )
} }
@ -508,7 +535,7 @@ fun AiDefinitionPopup(
) { ) {
val ttsController = rememberTtsController() val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState() val ttsState by ttsController.ttsState.collectAsState()
LocalContext.current val context = LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -593,7 +620,7 @@ fun AiDefinitionPopup(
bookTitle = "AI Definition", bookTitle = "AI Definition",
chapterTitle = word, chapterTitle = word,
coverImageUri = null, coverImageUri = null,
ttsMode = "BASE", ttsMode = loadTtsMode(context),
playbackSource = "POPUP" playbackSource = "POPUP"
) )
} }
@ -982,3 +1009,590 @@ suspend fun fetchRecap(
} }
} }
} }
@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<TextToSpeech?>(null) }
var allVoices by remember { mutableStateOf<List<Voice>>(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<String, String>()
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
)
}
}
}
}
}
}
}
}
}

View file

@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close 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.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@ -145,6 +146,8 @@ fun EpubReaderTopBar(
onToggleVolumeScroll: (Boolean) -> Unit, onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit, onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -268,6 +271,33 @@ fun EpubReaderTopBar(
onStartAutoScroll() 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))
}
)
}
} }
} }
} }

View file

@ -127,10 +127,12 @@ import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BannerMessage import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook 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.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
import com.aryan.reader.tts.SpeakerSamplePlayer 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.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job 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_MIN_SPEED_KEY = "auto_scroll_min_speed"
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_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 PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
private const val TTS_MODE_KEY = "tts_mode"
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) { private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) 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) 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) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
fun EpubReaderScreen( fun EpubReaderScreen(
@ -325,6 +333,8 @@ fun EpubReaderHost(
mutableStateOf(loadPageTurnAnimationSetting(context)) mutableStateOf(loadPageTurnAnimationSetting(context))
} }
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
val locatorConverter = remember(context) { val locatorConverter = remember(context) {
LocatorConverter( LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(), bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
@ -689,7 +699,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title, bookTitle = epubBook.title,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER"
) )
} }
@ -708,6 +718,8 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
TtsSessionObserver( TtsSessionObserver(
ttsState = ttsState, ttsState = ttsState,
@ -731,7 +743,8 @@ fun EpubReaderHost(
}, },
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts, userStoppedTts = userStoppedTts,
scope = scope scope = scope,
currentTtsMode = currentTtsMode
) )
TtsHighlightHandler( TtsHighlightHandler(
@ -1917,7 +1930,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title, bookTitle = epubBook.title,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER"
) )
} else { } else {
@ -2741,7 +2754,9 @@ fun EpubReaderHost(
showBars = true showBars = true
}, },
searchFocusRequester = searchFocusRequester, 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( val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
@ -3206,6 +3221,31 @@ fun EpubReaderHost(
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total) 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) { if (showFontSelectionSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showFontSelectionSheet = false }, onDismissRequest = { showFontSelectionSheet = false },

View file

@ -45,7 +45,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
private const val TTS_MODE_KEY = "tts_mode" private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo( data class TtsHighlightInfo(
@ -61,13 +60,6 @@ fun saveTtsMode(context: Context, mode: TtsMode) {
prefs.edit { putString(TTS_MODE_KEY, mode.name) } 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. * Helper to update the WebView auto-scroll state.
*/ */
@ -121,20 +113,20 @@ fun TtsSessionObserver(
onNavigateToChapter: (Int) -> Unit, onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit, onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean, userStoppedTts: Boolean,
scope: CoroutineScope scope: CoroutineScope,
currentTtsMode: TtsMode
) { ) {
val prevTtsState = remember { mutableStateOf(ttsState) } val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) { LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying val isPlaying = ttsState.isPlaying
val isChangingConfig = ttsState.isChangingConfig
val sessionFinished = ttsState.sessionFinished val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER" val isReaderSource = ttsState.playbackSource == "READER"
if (!isChangingConfig && isReaderSource) { if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) { if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.") Timber.d("TTS finished naturally. Checking for next content.")
@ -161,7 +153,8 @@ fun TtsSessionObserver(
epubBookTitle = epubBookTitle, epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange, onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope scope = scope,
ttsMode = currentTtsMode
) )
} }
} else if (wasPlaying && !isPlaying && !sessionFinished) { } else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -271,14 +264,14 @@ private fun handlePaginatedAutoAdvance(
epubBookTitle: String, epubBookTitle: String,
coverImagePath: String?, coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit, onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope scope: CoroutineScope,
ttsMode: TtsMode
) { ) {
val lastPlayedChapter = currentTtsChapterIndex if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...") Timber.d("Paginated: Searching for next TTS content...")
scope.launch { scope.launch {
var chapterToTry = lastPlayedChapter + 1 var chapterToTry = currentTtsChapterIndex + 1
var foundContent = false var foundContent = false
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
@ -288,7 +281,6 @@ private fun handlePaginatedAutoAdvance(
} }
while (chapterToTry < chapters.size) { while (chapterToTry < chapters.size) {
// Visually scroll to start of chapter
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry] val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) { if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage) pagerState.animateScrollToPage(targetPage)
@ -309,13 +301,12 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = "BASE" // Defaulting to Base for safety ttsMode = ttsMode
) )
foundContent = true foundContent = true
break break
} else { } else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.") Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
// Visually flip through empty pages if needed
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0 val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) { if (pageCount > 1) {
for (i in 1 until pageCount) { for (i in 1 until pageCount) {

View file

@ -26,12 +26,6 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context 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.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.RectF 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.Edit
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit 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.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp 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.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow 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.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb 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.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.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale 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.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -224,13 +223,14 @@ import androidx.paging.compose.itemKey
import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult 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.epubreader.AutoScrollControls
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.paginatedreader.TtsChunk 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.rememberSearchState
import com.aryan.reader.summarizationUrl import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer 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.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.PdfDocument import io.legere.pdfiumandroid.PdfDocument
@ -263,7 +265,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
@ -473,11 +474,6 @@ private fun loadPdfDarkMode(context: Context): Boolean {
return prefs.getBoolean(PDF_DARK_MODE_KEY, false) return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
} }
@Suppress("unused")
private enum class TtsMode {
CLOUD, BASE
}
private data class TtsPageData( private data class TtsPageData(
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
) )
@ -501,18 +497,13 @@ private fun flattenToc(bookmarks: List<PdfDocument.Bookmark>, level: Int = 0): L
return entries return entries
} }
@OptIn(UnstableApi::class)
@Suppress("unused") @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) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) } 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? { private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
var page: PdfPageKt? = null var page: PdfPageKt? = null
@ -658,6 +649,10 @@ fun PdfViewerScreen(
var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) } var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(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) { fun triggerAutoScrollTempPause(durationMs: Long) {
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
@ -1981,7 +1976,7 @@ fun PdfViewerScreen(
bookTitle = bookTitle, bookTitle = bookTitle,
chapterTitle = pageTitle, chapterTitle = pageTitle,
coverImageUri = null, coverImageUri = null,
ttsMode = TtsMode.BASE.name, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER"
) )
@ -2688,6 +2683,8 @@ fun PdfViewerScreen(
searchState.onQueryChange("") searchState.onQueryChange("")
} }
showTtsSettingsSheet -> showTtsSettingsSheet = false
else -> { else -> {
saveStateAndExit() saveStateAndExit()
} }
@ -4137,6 +4134,36 @@ fun PdfViewerScreen(
showBars = true 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() HorizontalDivider()
DropdownMenuItem(text = { DropdownMenuItem(text = {
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) { if (clickedLinkUrl != null) {
val url = clickedLinkUrl!! val url = clickedLinkUrl!!
AlertDialog( AlertDialog(

View file

@ -23,6 +23,7 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import com.aryan.reader.loadNativeVoice
import timber.log.Timber import timber.log.Timber
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@ -102,7 +103,6 @@ class BaseTtsSynthesizer(private val context: Context) {
if (isInitialized) return if (isInitialized) return
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
Timber.d("BaseTts: Initializing TextToSpeech engine...") Timber.d("BaseTts: Initializing TextToSpeech engine...")
// Use Application Context to prevent memory leaks and detachment issues
tts = TextToSpeech(context.applicationContext) { status -> tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) { if (status == TextToSpeech.SUCCESS) {
isInitialized = true isInitialized = true
@ -137,11 +137,38 @@ class BaseTtsSynthesizer(private val context: Context) {
} finally { } finally {
tts = null tts = null
isInitialized = false isInitialized = false
// COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again.
delay(350) 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<File?, String?> { suspend fun synthesizeToFile(text: String): Pair<File?, String?> {
if (text.isBlank()) { if (text.isBlank()) {
return Pair(null, text) return Pair(null, text)
@ -154,7 +181,6 @@ class BaseTtsSynthesizer(private val context: Context) {
val utteranceId = UUID.randomUUID().toString() val utteranceId = UUID.randomUUID().toString()
val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir) val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir)
// Prepare signals
val resultDeferred = CompletableDeferred<Pair<File?, String?>>() val resultDeferred = CompletableDeferred<Pair<File?, String?>>()
val startSignal = CompletableDeferred<Unit>() val startSignal = CompletableDeferred<Unit>()
@ -170,12 +196,12 @@ class BaseTtsSynthesizer(private val context: Context) {
} }
} }
applyPreferredVoice()
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId") Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text) requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
// Prevention: No tts?.stop() here.
val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId) val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId)
if (ttsResult == TextToSpeech.ERROR) { if (ttsResult == TextToSpeech.ERROR) {
@ -202,7 +228,7 @@ class BaseTtsSynthesizer(private val context: Context) {
if (finalResult.first != null) { if (finalResult.first != null) {
result = finalResult result = finalResult
break // Success! break
} else { } else {
Timber.w("BaseTts: onError received during processing.") Timber.w("BaseTts: onError received during processing.")
throw IllegalStateException("TTS Engine reported onError") throw IllegalStateException("TTS Engine reported onError")

View file

@ -36,6 +36,7 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig
import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors 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 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 @UnstableApi
class TtsController(context: Context) : Player.Listener { class TtsController(context: Context) : Player.Listener {
@ -135,7 +158,7 @@ class TtsController(context: Context) : Player.Listener {
bookTitle: String, bookTitle: String,
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
ttsMode: String, ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER" playbackSource: String = "READER"
) { ) {
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
@ -143,7 +166,6 @@ class TtsController(context: Context) : Player.Listener {
return return
} }
Timber.d("UI sending START command with mode: $ttsMode") 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 textList = ArrayList(chunks.map { it.text })
val cfiList = ArrayList(chunks.map { it.sourceCfi }) val cfiList = ArrayList(chunks.map { it.sourceCfi })
@ -157,7 +179,7 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri) putString(KEY_COVER_IMAGE_URI, coverImageUri)
putString(KEY_TTS_MODE, ttsMode) putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_PLAYBACK_SOURCE, playbackSource)
} }
mediaController?.sendCustomCommand(START_TTS_COMMAND, args) 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 serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false) val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
val isLoading = customState.getBoolean("isLoading", false) val isLoading = customState.getBoolean("isLoading", false)
val isChangingConfig = customState.getBoolean("isChangingConfig", false)
val sessionFinished = customState.getBoolean("sessionFinished", false) val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource") val playbackSource = customState.getString("playbackSource")
@ -256,7 +277,6 @@ class TtsController(context: Context) : Player.Listener {
sessionEndedByStop = sessionEndedByStop, sessionEndedByStop = sessionEndedByStop,
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null, currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1, currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
isChangingConfig = isChangingConfig,
sessionFinished = sessionFinished, sessionFinished = sessionFinished,
playbackSource = playbackSource playbackSource = playbackSource
) )

View file

@ -80,7 +80,6 @@ class TtsPlaybackManager(
private val prefetchingJobs = mutableMapOf<Int, Job>() private val prefetchingJobs = mutableMapOf<Int, Job>()
private var wordTrackingJob: Job? = null private var wordTrackingJob: Job? = null
private var preparationJob: Job? = null private var preparationJob: Job? = null
private var isChangingConfig = false
enum class TtsMode { enum class TtsMode {
CLOUD, BASE CLOUD, BASE
@ -98,7 +97,6 @@ class TtsPlaybackManager(
val sessionEndedByStop: Boolean = false, val sessionEndedByStop: Boolean = false,
val currentWordSourceCfi: String? = null, val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1, val currentWordStartOffset: Int = -1,
val isChangingConfig: Boolean = false,
val sessionFinished: Boolean = false, val sessionFinished: Boolean = false,
val playbackSource: String? = null val playbackSource: String? = null
) )
@ -211,29 +209,8 @@ class TtsPlaybackManager(
private fun handleChangeTtsMode(newMode: TtsMode) { private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return if (currentTtsMode == newMode) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentTtsMode = newMode currentTtsMode = newMode
if (textChunks.isEmpty()) { Timber.d("TTS Mode changed to $newMode (pending next start)")
_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)
}
} }
private fun handleStartTts( private fun handleStartTts(
@ -245,10 +222,6 @@ class TtsPlaybackManager(
ttsMode: TtsMode, ttsMode: TtsMode,
playbackSource: String? playbackSource: String?
) { ) {
if (isChangingConfig) {
Timber.w("Ignoring START command because a config change is already in progress.")
return
}
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
return return
@ -269,36 +242,15 @@ class TtsPlaybackManager(
private fun handleChangeSpeaker(newSpeakerId: String) { private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return if (currentSpeakerId == newSpeakerId) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentSpeakerId = newSpeakerId currentSpeakerId = newSpeakerId
if (textChunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId) _ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
isChangingConfig = false Timber.d("Speaker changed to $newSpeakerId (pending next start)")
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)
}
} }
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) { private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
val firstChunk = textChunks.getOrNull(startAtIndex) val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) { if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.", isChangingConfig = false) _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
withContext(Dispatchers.Main) { isChangingConfig = false }
return return
} }
@ -323,20 +275,17 @@ class TtsPlaybackManager(
player.seekTo(startAtPosition) player.seekTo(startAtPosition)
} }
player.playWhenReady = playWhenReady player.playWhenReady = playWhenReady
isChangingConfig = false
_ttsState.value = _ttsState.value.copy( _ttsState.value = _ttsState.value.copy(
isLoading = false, isLoading = false,
isPlaying = playWhenReady, isPlaying = playWhenReady,
currentText = serverText, currentText = serverText,
sourceCfi = updatedChunk.sourceCfi, sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource, startOffsetInSource = updatedChunk.startOffsetInSource
isChangingConfig = false
) )
} }
prefetchNextChunkAudio(startAtIndex) prefetchNextChunkAudio(startAtIndex)
} else { } else {
withContext(Dispatchers.Main) { isChangingConfig = false } _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false)
} }
} }
@ -456,7 +405,6 @@ class TtsPlaybackManager(
if (isLastChunkInSession || textChunks.isEmpty()) { if (isLastChunkInSession || textChunks.isEmpty()) {
nextState = nextState.copy(sessionFinished = true) nextState = nextState.copy(sessionFinished = true)
} else { } else {
// Check if prefetch is active for the next chunk
val nextIdx = currentChunkIndex + 1 val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx) val isPrefetching = prefetchingJobs.containsKey(nextIdx)
@ -472,9 +420,6 @@ class TtsPlaybackManager(
_ttsState.value = nextState _ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) { if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (isChangingConfig) {
return
}
if (!nextState.sessionEndedByStop) { if (!nextState.sessionEndedByStop) {
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
} }
@ -620,7 +565,6 @@ class TtsPlaybackManager(
putBoolean("sessionEndedByStop", state.sessionEndedByStop) putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi) putString("currentWordSourceCfi", state.currentWordSourceCfi)
putInt("currentWordStartOffset", state.currentWordStartOffset) putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("isChangingConfig", state.isChangingConfig)
putBoolean("sessionFinished", state.sessionFinished) putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource) putString("playbackSource", state.playbackSource)
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -34,7 +35,7 @@ import org.json.JSONObject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL 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." 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."