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