fix: resolve TTS pause/resume functionality and enhance text highlighting
This commit is contained in:
parent
12ad5481c4
commit
dfbe9cba06
app/src/main/java/com/voca/app
File diff suppressed because it is too large
Load Diff
95
app/src/main/java/com/voca/app/domain/util/SentenceParser.kt
Normal file
95
app/src/main/java/com/voca/app/domain/util/SentenceParser.kt
Normal file
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user