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

@ -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<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
)
}
}
}
}
}
}
}
}
}