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:
parent
1ce353ad44
commit
3dc1843801
10 changed files with 869 additions and 150 deletions
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue