fix: resolve TTS pause/resume functionality and enhance text highlighting

This commit is contained in:
dave 2025-04-02 23:55:41 +03:00
parent 12ad5481c4
commit dfbe9cba06
3 changed files with 614 additions and 607 deletions
app/src/main/java/com/voca/app

File diff suppressed because it is too large Load Diff

@ -0,0 +1,95 @@
package com.voca.app.domain.util
import com.voca.app.domain.model.TextRange
import timber.log.Timber
/**
* Utility object for splitting text into sentences using smarter parsing logic.
* Handles nested punctuation, quotes, and abbreviations better than simple regex.
*/
object SentenceParser {
/**
* Parse text into a list of TextRange objects, including start and end indices.
* This better handles:
* - Abbreviations (Mr., Dr., etc.)
* - Quoted speech with ending punctuation
* - Line breaks and spacing
*
* @param text The text to parse into sentences
* @return List of parsed TextRange objects, with blank entries filtered out
*/
fun parse(text: String): List<TextRange> {
Timber.d("Parsing text into sentences with indices: ${text.length} chars")
if (text.isBlank()) {
return emptyList()
}
val sentences = mutableListOf<TextRange>()
val regex = Regex("""(?<=[.!?])\s+(?=[A-Z])""")
var startIndex = 0
regex.findAll(text).forEach { matchResult ->
val endIndex = matchResult.range.first + 1
val sentenceText = text.substring(startIndex, endIndex).trim()
if (sentenceText.isNotBlank()) {
sentences.add(
TextRange(
index = sentences.size,
text = sentenceText,
start = startIndex,
end = endIndex
)
)
}
startIndex = endIndex
}
if (startIndex < text.length) {
val lastSentenceText = text.substring(startIndex).trim()
if (lastSentenceText.isNotBlank()) {
sentences.add(
TextRange(
index = sentences.size,
text = lastSentenceText,
start = startIndex,
end = text.length
)
)
}
}
Timber.d("Parsed ${sentences.size} sentences with indices from text")
return sentences.filter { it.text.isNotBlank() }
}
/**
* Advanced parsing with additional options for controlling the parsing process.
* Returns TextRange objects with indices for better tracking.
*
* @param text The text to parse
* @param minLength Minimum character length to consider a valid sentence (default: 2)
* @param preserveLineBreaks Whether to treat line breaks as sentence boundaries (default: true)
* @return List of parsed TextRange objects
*/
fun parseAdvanced(
text: String,
minLength: Int = 2,
preserveLineBreaks: Boolean = true
): List<TextRange> {
if (text.isBlank()) {
return emptyList()
}
val processedText = if (preserveLineBreaks) {
text.replace(Regex("\\n{2,}"), ". ")
.replace("\n", " ")
} else {
text.replace("\n", " ")
}
// Use the main parse method to create TextRange objects
return parse(processedText).filter { it.text.length >= minLength }
}
}

@ -94,6 +94,12 @@ import kotlinx.coroutines.Job
import androidx.compose.material.icons.filled.Replay
import kotlin.math.roundToInt
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
import androidx.compose.material.icons.automirrored.filled.NavigateNext
/**
* MainScreen is the primary UI component for the Voca app
@ -276,20 +282,14 @@ fun MainScreen(
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Display Area for Text Content
OutlinedTextField(
value = currentPageText,
onValueChange = { /* Read-only, no action */ },
label = { Text("Document Content") },
// Text Display Area
TextDisplayArea(
text = currentPageText,
ttsState = ttsState,
modifier = Modifier
.weight(1f) // Takes up available space
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
textStyle = MaterialTheme.typography.bodyLarge.copy(
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.5
),
readOnly = true,
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors()
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
)
Spacer(modifier = Modifier.height(16.dp))
@ -311,6 +311,8 @@ fun MainScreen(
onPlayPauseToggle = {
if (ttsState.isSpeaking && !ttsState.isPaused) {
(viewModel::onAction)(MainAction.PauseSpeech)
} else if (ttsState.isPaused) {
(viewModel::onAction)(MainAction.ResumeSpeech)
} else {
(viewModel::onAction)(MainAction.PlayText)
}
@ -387,14 +389,20 @@ fun PaginationControls(
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onPrevious, enabled = isPreviousEnabled) {
Icon(Icons.Filled.NavigateBefore, contentDescription = "Previous Page")
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous Sentence"
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text("Previous")
}
Button(onClick = onNext, enabled = isNextEnabled) {
Text("Next")
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon(Icons.Filled.NavigateNext, contentDescription = "Next Page")
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next Sentence"
)
}
}
}
@ -492,7 +500,7 @@ fun MainScreenPreviewDark() {
)
when(genState) {
is Mp3GenerationProgress.InProgress -> LinearProgressIndicator(progress = genState.progress, modifier = Modifier.fillMaxWidth())
is Mp3GenerationProgress.InProgress -> LinearProgressIndicator(progress = { genState.progress }, modifier = Modifier.fillMaxWidth())
else -> {}
}
@ -553,7 +561,7 @@ fun Mp3PlayerControls(
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = mp3GenerationState.progress,
progress = { mp3GenerationState.progress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp), // Make progress bar thicker
@ -657,4 +665,74 @@ private fun formatDuration(millis: Int): String {
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis.toLong())
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis.toLong()) % 60
return String.format("%02d:%02d", minutes, seconds)
}
@Composable
fun TextDisplayArea(
text: String,
ttsState: TTSState,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val currentSentenceIndex = ttsState.currentSentenceIndex
val sentences = ttsState.sentences
// Auto-scroll to current sentence when it changes
LaunchedEffect(currentSentenceIndex) {
if (currentSentenceIndex >= 0 && currentSentenceIndex < sentences.size) {
val sentence = sentences[currentSentenceIndex]
// Calculate approximate scroll position based on sentence start position relative to total text
val scrollPosition = (sentence.start.toFloat() / text.length.toFloat() * scrollState.maxValue).toInt()
scrollState.animateScrollTo(scrollPosition)
}
}
val annotatedString = buildAnnotatedString {
if (sentences.isEmpty()) {
// If no sentences parsed, just display the regular text
append(text)
} else {
var currentIndex = 0
sentences.forEachIndexed { index, range ->
// Append text before the sentence if there's a gap
if (range.start > currentIndex) {
append(text.substring(currentIndex, range.start))
}
// Apply highlighting if this is the current sentence
if (index == currentSentenceIndex) {
withStyle(style = SpanStyle(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)) {
append(range.text)
}
} else {
append(range.text)
}
currentIndex = range.end
}
// Append any remaining text after the last sentence
if (currentIndex < text.length) {
append(text.substring(currentIndex))
}
}
}
Column(
modifier = modifier
.verticalScroll(scrollState)
.padding(16.dp)
) {
// Using BasicText with annotatedString for highlighting
BasicText(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onBackground,
fontSize = 18.sp, // Example size, consider making configurable
lineHeight = 24.sp // Add some line spacing for readability
)
)
}
}