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
|
|
@ -47,6 +47,7 @@ android {
|
|||
buildConfigField("String", "VERIFIER_WORKER_URL", "\"\"")
|
||||
buildConfigField("String", "FEEDBACK_WORKER_URL", "\"\"")
|
||||
buildConfigField("boolean", "IS_PRO", "false")
|
||||
buildConfigField("String", "TTS_WORKER_URL", "\"\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.Check
|
|||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
|
|
@ -145,6 +146,8 @@ fun EpubReaderTopBar(
|
|||
onToggleVolumeScroll: (Boolean) -> Unit,
|
||||
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
onOpenTtsSettings: () -> Unit,
|
||||
onOpenDeviceVoiceSettings: () -> Unit,
|
||||
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -268,6 +271,33 @@ fun EpubReaderTopBar(
|
|||
onStartAutoScroll()
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// *** ADDITION START ***
|
||||
DropdownMenuItem(
|
||||
text = { Text("TTS Voice Settings") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenDeviceVoiceSettings()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("TTS Settings (Debug)") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsSettings()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,10 +127,12 @@ import com.aryan.reader.AiDefinitionResult
|
|||
import com.aryan.reader.BannerMessage
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.CustomTopBanner
|
||||
import com.aryan.reader.DeviceVoiceSettingsSheet
|
||||
import com.aryan.reader.RenderMode
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.countWords
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
|
|
@ -150,7 +152,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
|||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import com.aryan.reader.rememberSearchState
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||
import com.aryan.reader.tts.loadTtsMode
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -177,6 +179,7 @@ private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
|
|||
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
|
||||
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
|
||||
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
|
||||
private const val TTS_MODE_KEY = "tts_mode"
|
||||
|
||||
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
|
|
@ -229,6 +232,11 @@ private fun loadAutoScrollUseSlider(context: Context): Boolean {
|
|||
return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false)
|
||||
}
|
||||
|
||||
private fun saveTtsMode(context: Context, modeName: String) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TTS_MODE_KEY, modeName) }
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
fun EpubReaderScreen(
|
||||
|
|
@ -325,6 +333,8 @@ fun EpubReaderHost(
|
|||
mutableStateOf(loadPageTurnAnimationSetting(context))
|
||||
}
|
||||
|
||||
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
|
||||
|
||||
val locatorConverter = remember(context) {
|
||||
LocatorConverter(
|
||||
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
|
||||
|
|
@ -689,7 +699,7 @@ fun EpubReaderHost(
|
|||
bookTitle = epubBook.title,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = TtsMode.BASE.name,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
)
|
||||
}
|
||||
|
|
@ -708,6 +718,8 @@ fun EpubReaderHost(
|
|||
|
||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
TtsSessionObserver(
|
||||
ttsState = ttsState,
|
||||
|
|
@ -731,7 +743,8 @@ fun EpubReaderHost(
|
|||
},
|
||||
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
|
||||
userStoppedTts = userStoppedTts,
|
||||
scope = scope
|
||||
scope = scope,
|
||||
currentTtsMode = currentTtsMode
|
||||
)
|
||||
|
||||
TtsHighlightHandler(
|
||||
|
|
@ -1917,7 +1930,7 @@ fun EpubReaderHost(
|
|||
bookTitle = epubBook.title,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = TtsMode.BASE.name,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
)
|
||||
} else {
|
||||
|
|
@ -2741,7 +2754,9 @@ fun EpubReaderHost(
|
|||
showBars = true
|
||||
},
|
||||
searchFocusRequester = searchFocusRequester,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }
|
||||
)
|
||||
|
||||
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
|
||||
|
|
@ -3206,6 +3221,31 @@ fun EpubReaderHost(
|
|||
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
|
||||
}
|
||||
|
||||
if (showTtsSettingsSheet) {
|
||||
TtsSettingsSheet(
|
||||
isVisible = true,
|
||||
onDismiss = { showTtsSettingsSheet = false },
|
||||
currentMode = currentTtsMode,
|
||||
onModeChange = { newMode ->
|
||||
currentTtsMode = newMode
|
||||
saveTtsMode(context, newMode.name)
|
||||
ttsController.changeTtsMode(newMode.name)
|
||||
},
|
||||
currentSpeakerId = ttsState.speakerId,
|
||||
onSpeakerChange = { newSpeaker ->
|
||||
ttsController.changeSpeaker(newSpeaker)
|
||||
},
|
||||
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER"
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeviceVoiceSettingsSheet) {
|
||||
DeviceVoiceSettingsSheet(
|
||||
isVisible = true,
|
||||
onDismiss = { showDeviceVoiceSettingsSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showFontSelectionSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showFontSelectionSheet = false },
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
|
||||
private const val TTS_MODE_KEY = "tts_mode"
|
||||
|
||||
data class TtsHighlightInfo(
|
||||
|
|
@ -61,13 +60,6 @@ fun saveTtsMode(context: Context, mode: TtsMode) {
|
|||
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@OptIn(UnstableApi::class)
|
||||
fun loadTtsMode(): TtsMode {
|
||||
// For this release, Cloud TTS is disabled. Force BASE mode.
|
||||
return TtsMode.BASE
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update the WebView auto-scroll state.
|
||||
*/
|
||||
|
|
@ -121,20 +113,20 @@ fun TtsSessionObserver(
|
|||
onNavigateToChapter: (Int) -> Unit,
|
||||
onToggleTtsStartOnLoad: (Boolean) -> Unit,
|
||||
userStoppedTts: Boolean,
|
||||
scope: CoroutineScope
|
||||
scope: CoroutineScope,
|
||||
currentTtsMode: TtsMode
|
||||
) {
|
||||
val prevTtsState = remember { mutableStateOf(ttsState) }
|
||||
|
||||
LaunchedEffect(ttsState) {
|
||||
val wasPlaying = prevTtsState.value.isPlaying
|
||||
val isPlaying = ttsState.isPlaying
|
||||
val isChangingConfig = ttsState.isChangingConfig
|
||||
val sessionFinished = ttsState.sessionFinished
|
||||
val wasSessionFinished = prevTtsState.value.sessionFinished
|
||||
val sessionEndedByStop = ttsState.sessionEndedByStop
|
||||
val isReaderSource = ttsState.playbackSource == "READER"
|
||||
|
||||
if (!isChangingConfig && isReaderSource) {
|
||||
if (isReaderSource) {
|
||||
if (sessionFinished && !wasSessionFinished) {
|
||||
Timber.d("TTS finished naturally. Checking for next content.")
|
||||
|
||||
|
|
@ -161,7 +153,8 @@ fun TtsSessionObserver(
|
|||
epubBookTitle = epubBookTitle,
|
||||
coverImagePath = coverImagePath,
|
||||
onUpdateTtsChapter = onTtsChapterIndexChange,
|
||||
scope = scope
|
||||
scope = scope,
|
||||
ttsMode = currentTtsMode
|
||||
)
|
||||
}
|
||||
} else if (wasPlaying && !isPlaying && !sessionFinished) {
|
||||
|
|
@ -271,14 +264,14 @@ private fun handlePaginatedAutoAdvance(
|
|||
epubBookTitle: String,
|
||||
coverImagePath: String?,
|
||||
onUpdateTtsChapter: (Int?) -> Unit,
|
||||
scope: CoroutineScope
|
||||
scope: CoroutineScope,
|
||||
ttsMode: TtsMode
|
||||
) {
|
||||
val lastPlayedChapter = currentTtsChapterIndex
|
||||
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
|
||||
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
|
||||
Timber.d("Paginated: Searching for next TTS content...")
|
||||
|
||||
scope.launch {
|
||||
var chapterToTry = lastPlayedChapter + 1
|
||||
var chapterToTry = currentTtsChapterIndex + 1
|
||||
var foundContent = false
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
|
||||
|
|
@ -288,7 +281,6 @@ private fun handlePaginatedAutoAdvance(
|
|||
}
|
||||
|
||||
while (chapterToTry < chapters.size) {
|
||||
// Visually scroll to start of chapter
|
||||
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
|
||||
if (targetPage != null && pagerState.currentPage != targetPage) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
|
|
@ -309,13 +301,12 @@ private fun handlePaginatedAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = "BASE" // Defaulting to Base for safety
|
||||
ttsMode = ttsMode
|
||||
)
|
||||
foundContent = true
|
||||
break
|
||||
} else {
|
||||
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
|
||||
// Visually flip through empty pages if needed
|
||||
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
|
||||
if (pageCount > 1) {
|
||||
for (i in 1 until pageCount) {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@ import android.Manifest
|
|||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
|
|
@ -99,6 +93,7 @@ import androidx.compose.material.icons.filled.Check
|
|||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
|
|
@ -134,10 +129,8 @@ import androidx.compose.material3.OutlinedTextField
|
|||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
|
|
@ -174,13 +167,18 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -194,6 +192,7 @@ import androidx.compose.ui.platform.LocalView
|
|||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
|
@ -224,13 +223,14 @@ import androidx.paging.compose.itemKey
|
|||
import com.aryan.reader.AiDefinitionPopup
|
||||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.DeviceVoiceSettingsSheet
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.SummarizationPopup
|
||||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SyncUpdateInfo
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.epubreader.AutoScrollControls
|
||||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
|
|
@ -246,6 +246,8 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.rememberSearchState
|
||||
import com.aryan.reader.summarizationUrl
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
import com.aryan.reader.tts.loadTtsMode
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import io.legere.pdfiumandroid.PdfDocument
|
||||
|
|
@ -263,7 +265,6 @@ import kotlinx.coroutines.flow.conflate
|
|||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
|
|
@ -473,11 +474,6 @@ private fun loadPdfDarkMode(context: Context): Boolean {
|
|||
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private enum class TtsMode {
|
||||
CLOUD, BASE
|
||||
}
|
||||
|
||||
private data class TtsPageData(
|
||||
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
|
||||
)
|
||||
|
|
@ -501,18 +497,13 @@ private fun flattenToc(bookmarks: List<PdfDocument.Bookmark>, level: Int = 0): L
|
|||
return entries
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Suppress("unused")
|
||||
private fun saveTtsMode(context: Context, mode: TtsMode) {
|
||||
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun loadTtsMode(): TtsMode {
|
||||
// For this release, Cloud TTS is disabled. Force BASE mode.
|
||||
return TtsMode.BASE
|
||||
}
|
||||
|
||||
private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var page: PdfPageKt? = null
|
||||
|
|
@ -658,6 +649,10 @@ fun PdfViewerScreen(
|
|||
|
||||
var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) }
|
||||
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
|
||||
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
|
||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
fun triggerAutoScrollTempPause(durationMs: Long) {
|
||||
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
|
||||
|
|
@ -1981,7 +1976,7 @@ fun PdfViewerScreen(
|
|||
bookTitle = bookTitle,
|
||||
chapterTitle = pageTitle,
|
||||
coverImageUri = null,
|
||||
ttsMode = TtsMode.BASE.name,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
)
|
||||
|
||||
|
|
@ -2688,6 +2683,8 @@ fun PdfViewerScreen(
|
|||
searchState.onQueryChange("")
|
||||
}
|
||||
|
||||
showTtsSettingsSheet -> showTtsSettingsSheet = false
|
||||
|
||||
else -> {
|
||||
saveStateAndExit()
|
||||
}
|
||||
|
|
@ -4137,6 +4134,36 @@ fun PdfViewerScreen(
|
|||
showBars = true
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("TTS Voice Settings") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
showDeviceVoiceSettingsSheet = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GraphicEq,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("TTS Settings (Debug)") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
showTtsSettingsSheet = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = {
|
||||
Text(
|
||||
|
|
@ -5410,6 +5437,31 @@ fun PdfViewerScreen(
|
|||
})
|
||||
}
|
||||
|
||||
if (showTtsSettingsSheet) {
|
||||
TtsSettingsSheet(
|
||||
isVisible = true,
|
||||
onDismiss = { showTtsSettingsSheet = false },
|
||||
currentMode = currentTtsMode,
|
||||
onModeChange = { newMode ->
|
||||
currentTtsMode = newMode
|
||||
saveTtsMode(context, newMode)
|
||||
ttsController.changeTtsMode(newMode.name)
|
||||
},
|
||||
currentSpeakerId = ttsState.speakerId,
|
||||
onSpeakerChange = { newSpeaker ->
|
||||
ttsController.changeSpeaker(newSpeaker)
|
||||
},
|
||||
isTtsActive = isTtsSessionActive
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeviceVoiceSettingsSheet) {
|
||||
DeviceVoiceSettingsSheet(
|
||||
isVisible = true,
|
||||
onDismiss = { showDeviceVoiceSettingsSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (clickedLinkUrl != null) {
|
||||
val url = clickedLinkUrl!!
|
||||
AlertDialog(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.content.Context
|
|||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import com.aryan.reader.loadNativeVoice
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
|
@ -102,7 +103,6 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
if (isInitialized) return
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
Timber.d("BaseTts: Initializing TextToSpeech engine...")
|
||||
// Use Application Context to prevent memory leaks and detachment issues
|
||||
tts = TextToSpeech(context.applicationContext) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
isInitialized = true
|
||||
|
|
@ -137,11 +137,38 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
} finally {
|
||||
tts = null
|
||||
isInitialized = false
|
||||
// COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again.
|
||||
delay(350)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPreferredVoice() {
|
||||
if (tts == null) return
|
||||
|
||||
try {
|
||||
val preferredVoiceName = loadNativeVoice(context) ?: return
|
||||
|
||||
if (tts?.voice?.name == preferredVoiceName) return
|
||||
|
||||
val availableVoices = tts?.voices
|
||||
if (availableVoices != null) {
|
||||
val targetVoice = availableVoices.find { it.name == preferredVoiceName }
|
||||
if (targetVoice != null) {
|
||||
Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})")
|
||||
tts?.voice = targetVoice
|
||||
try {
|
||||
tts?.language = targetVoice.locale
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to set language for voice")
|
||||
}
|
||||
} else {
|
||||
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to apply preferred voice")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun synthesizeToFile(text: String): Pair<File?, String?> {
|
||||
if (text.isBlank()) {
|
||||
return Pair(null, text)
|
||||
|
|
@ -154,7 +181,6 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
val utteranceId = UUID.randomUUID().toString()
|
||||
val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir)
|
||||
|
||||
// Prepare signals
|
||||
val resultDeferred = CompletableDeferred<Pair<File?, String?>>()
|
||||
val startSignal = CompletableDeferred<Unit>()
|
||||
|
||||
|
|
@ -170,12 +196,12 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
applyPreferredVoice()
|
||||
|
||||
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
|
||||
|
||||
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
|
||||
|
||||
// Prevention: No tts?.stop() here.
|
||||
|
||||
val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId)
|
||||
|
||||
if (ttsResult == TextToSpeech.ERROR) {
|
||||
|
|
@ -202,7 +228,7 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
|
||||
if (finalResult.first != null) {
|
||||
result = finalResult
|
||||
break // Success!
|
||||
break
|
||||
} else {
|
||||
Timber.w("BaseTts: onError received during processing.")
|
||||
throw IllegalStateException("TTS Engine reported onError")
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import androidx.media3.common.Player
|
|||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
|
@ -63,6 +64,28 @@ private fun loadSpeaker(context: Context): String {
|
|||
return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Suppress("KotlinConstantConditions")
|
||||
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||
|
||||
val isCloudAllowed = BuildConfig.DEBUG &&
|
||||
BuildConfig.IS_PRO &&
|
||||
BuildConfig.TTS_WORKER_URL.isNotBlank()
|
||||
|
||||
return if (isCloudAllowed) {
|
||||
try {
|
||||
TtsPlaybackManager.TtsMode.valueOf(savedModeName)
|
||||
} catch (_: Exception) {
|
||||
TtsPlaybackManager.TtsMode.BASE
|
||||
}
|
||||
} else {
|
||||
TtsPlaybackManager.TtsMode.BASE
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class TtsController(context: Context) : Player.Listener {
|
||||
|
||||
|
|
@ -135,7 +158,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
ttsMode: String,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER"
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
|
|
@ -143,7 +166,6 @@ class TtsController(context: Context) : Player.Listener {
|
|||
return
|
||||
}
|
||||
Timber.d("UI sending START command with mode: $ttsMode")
|
||||
Timber.d("TtsController: Sending START command. Chunk count: ${chunks.size}. Mode: $ttsMode. First chunk len: ${chunks.first().text.length}")
|
||||
|
||||
val textList = ArrayList(chunks.map { it.text })
|
||||
val cfiList = ArrayList(chunks.map { it.sourceCfi })
|
||||
|
|
@ -157,7 +179,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_BOOK_TITLE, bookTitle)
|
||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
putString(KEY_TTS_MODE, ttsMode)
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
}
|
||||
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
|
||||
|
|
@ -221,7 +243,6 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
|
||||
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
|
||||
val isLoading = customState.getBoolean("isLoading", false)
|
||||
val isChangingConfig = customState.getBoolean("isChangingConfig", false)
|
||||
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
||||
val playbackSource = customState.getString("playbackSource")
|
||||
|
||||
|
|
@ -256,7 +277,6 @@ class TtsController(context: Context) : Player.Listener {
|
|||
sessionEndedByStop = sessionEndedByStop,
|
||||
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
|
||||
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
|
||||
isChangingConfig = isChangingConfig,
|
||||
sessionFinished = sessionFinished,
|
||||
playbackSource = playbackSource
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ class TtsPlaybackManager(
|
|||
private val prefetchingJobs = mutableMapOf<Int, Job>()
|
||||
private var wordTrackingJob: Job? = null
|
||||
private var preparationJob: Job? = null
|
||||
private var isChangingConfig = false
|
||||
|
||||
enum class TtsMode {
|
||||
CLOUD, BASE
|
||||
|
|
@ -98,7 +97,6 @@ class TtsPlaybackManager(
|
|||
val sessionEndedByStop: Boolean = false,
|
||||
val currentWordSourceCfi: String? = null,
|
||||
val currentWordStartOffset: Int = -1,
|
||||
val isChangingConfig: Boolean = false,
|
||||
val sessionFinished: Boolean = false,
|
||||
val playbackSource: String? = null
|
||||
)
|
||||
|
|
@ -211,29 +209,8 @@ class TtsPlaybackManager(
|
|||
|
||||
private fun handleChangeTtsMode(newMode: TtsMode) {
|
||||
if (currentTtsMode == newMode) return
|
||||
preparationJob?.cancel()
|
||||
isChangingConfig = true
|
||||
val wasPlaying = player.isPlaying
|
||||
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
|
||||
val currentPosition = player.currentPosition
|
||||
currentTtsMode = newMode
|
||||
if (textChunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(isPlaying = false)
|
||||
isChangingConfig = false
|
||||
return
|
||||
}
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = true, isPlaying = false, isChangingConfig = true)
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
scope.launch {
|
||||
clearAudioFiles()
|
||||
}
|
||||
preparationJob = scope.launch {
|
||||
val startIndex = currentChunkIndex ?: 0
|
||||
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
|
||||
}
|
||||
Timber.d("TTS Mode changed to $newMode (pending next start)")
|
||||
}
|
||||
|
||||
private fun handleStartTts(
|
||||
|
|
@ -245,10 +222,6 @@ class TtsPlaybackManager(
|
|||
ttsMode: TtsMode,
|
||||
playbackSource: String?
|
||||
) {
|
||||
if (isChangingConfig) {
|
||||
Timber.w("Ignoring START command because a config change is already in progress.")
|
||||
return
|
||||
}
|
||||
if (chunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
||||
return
|
||||
|
|
@ -269,36 +242,15 @@ class TtsPlaybackManager(
|
|||
|
||||
private fun handleChangeSpeaker(newSpeakerId: String) {
|
||||
if (currentSpeakerId == newSpeakerId) return
|
||||
preparationJob?.cancel()
|
||||
isChangingConfig = true
|
||||
val wasPlaying = player.isPlaying
|
||||
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
|
||||
val currentPosition = player.currentPosition
|
||||
currentSpeakerId = newSpeakerId
|
||||
if (textChunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
|
||||
isChangingConfig = false
|
||||
return
|
||||
}
|
||||
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId, isLoading = true, isPlaying = false, isChangingConfig = true)
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
scope.launch {
|
||||
clearAudioFiles()
|
||||
}
|
||||
preparationJob = scope.launch {
|
||||
val startIndex = currentChunkIndex ?: 0
|
||||
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
|
||||
}
|
||||
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
|
||||
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
|
||||
}
|
||||
|
||||
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||
if (firstChunk == null) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.", isChangingConfig = false)
|
||||
withContext(Dispatchers.Main) { isChangingConfig = false }
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -323,20 +275,17 @@ class TtsPlaybackManager(
|
|||
player.seekTo(startAtPosition)
|
||||
}
|
||||
player.playWhenReady = playWhenReady
|
||||
isChangingConfig = false
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
isLoading = false,
|
||||
isPlaying = playWhenReady,
|
||||
currentText = serverText,
|
||||
sourceCfi = updatedChunk.sourceCfi,
|
||||
startOffsetInSource = updatedChunk.startOffsetInSource,
|
||||
isChangingConfig = false
|
||||
startOffsetInSource = updatedChunk.startOffsetInSource
|
||||
)
|
||||
}
|
||||
prefetchNextChunkAudio(startAtIndex)
|
||||
} else {
|
||||
withContext(Dispatchers.Main) { isChangingConfig = false }
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false)
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +405,6 @@ class TtsPlaybackManager(
|
|||
if (isLastChunkInSession || textChunks.isEmpty()) {
|
||||
nextState = nextState.copy(sessionFinished = true)
|
||||
} else {
|
||||
// Check if prefetch is active for the next chunk
|
||||
val nextIdx = currentChunkIndex + 1
|
||||
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
|
||||
|
||||
|
|
@ -472,9 +420,6 @@ class TtsPlaybackManager(
|
|||
_ttsState.value = nextState
|
||||
|
||||
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
|
||||
if (isChangingConfig) {
|
||||
return
|
||||
}
|
||||
if (!nextState.sessionEndedByStop) {
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
|
|
@ -620,7 +565,6 @@ class TtsPlaybackManager(
|
|||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||
putInt("currentWordStartOffset", state.currentWordStartOffset)
|
||||
putBoolean("isChangingConfig", state.isChangingConfig)
|
||||
putBoolean("sessionFinished", state.sessionFinished)
|
||||
putString("playbackSource", state.playbackSource)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -34,7 +35,7 @@ import org.json.JSONObject
|
|||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
const val googleCloudWorkerTtsUrl = ""
|
||||
const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL
|
||||
|
||||
const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark."
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue