Improve Android read aloud TTS handling

This commit is contained in:
sergeych committed 2026-05-21 11:07:42 +03:00
1 parent df90b4c36f
commit 8bb920caf8
11 files changed
+870 -71

No files matched your search

@@ -8,6 +8,7 @@ data class ReadAloudSentence(
val start: Int,
val endExclusive: Int,
val text: String,
val spokenText: String = text,
val pauseBeforeMillis: Long = 0,
val pauseAfterMillis: Long = 0,
)
@@ -18,12 +19,37 @@ data class ReadAloudState(
val sentenceIndex: Int? = null,
)
data class ReadAloudEngineOption(
val id: String,
val label: String,
)
data class ReadAloudVoiceOption(
val id: String,
val label: String,
val localeTag: String,
val networkRequired: Boolean,
)
data class ReadAloudSettingsState(
val engines: List<ReadAloudEngineOption> = emptyList(),
val selectedEngineId: String? = null,
val voices: List<ReadAloudVoiceOption> = emptyList(),
val selectedVoiceId: String? = null,
val loading: Boolean = false,
val message: String? = null,
)
expect object ReadAloudPlatform {
val isSupported: Boolean
val state: StateFlow<ReadAloudState>
val settingsState: StateFlow<ReadAloudSettingsState>
fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int)
fun play()
fun stop()
fun skip(delta: Int)
fun refreshSettings()
fun selectEngine(engineId: String?)
fun selectVoice(voiceId: String?)
}
@@ -93,11 +93,7 @@ internal fun ContinuousBookReader(
val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
val contentPadding = if (isAndroidPlatform()) {
PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
} else {
PaddingValues(horizontal = 4.dp, vertical = 6.dp)
}
val contentPadding = PaddingValues(6.dp)
LazyColumn(
state = listState,
@@ -174,10 +170,8 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
highlightedRange = highlightedRange,
/* Justify adds extra padding to the end, which hardly can be removed */
textAlign = TextAlign.Justify,
// so we add 6.dp to make it look symmetric
modifier = Modifier.padding(start = (element.depth * 8).dp + 6.dp, end = 0.dp),
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
)
is ReaderElement.Subtitle -> ReaderText(
@@ -444,7 +438,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
textAlign = TextAlign.Justify,
textAlign = TextAlign.Unspecified,
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
@@ -473,7 +467,7 @@ private fun ReaderText(
) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
val needsSoftHyphenPaintWorkaround = isDesktopPlatform()
val needsSoftHyphenPaintWorkaround = isDesktopPlatform
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
val desktopHyphenGutter = 8.dp
@@ -525,19 +519,22 @@ private fun ReaderText(
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
lineHeight = 28.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
fontWeight = if( isAndroidPlatform) FontWeight(350) else FontWeight.Normal,
fontSize = if( isAndroidPlatform) 19.sp else 18.sp,
lineHeight = 26.sp,
letterSpacing = if (isAndroidPlatform) 0.sp else MaterialTheme.typography.bodyLarge.letterSpacing,
hyphens = if (isAndroidPlatform) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
private fun isAndroidPlatform(): Boolean =
private val isAndroidPlatform: Boolean by lazy {
getPlatform().name.startsWith("Android")
}
private fun isDesktopPlatform(): Boolean =
private val isDesktopPlatform: Boolean by lazy {
getPlatform().name.startsWith("Java")
}
private fun TextLayoutResult.endsAtSoftHyphen(text: String, line: Int): Boolean {
val end = getLineEnd(line, visibleEnd = false)
@@ -663,6 +660,7 @@ private fun AnnotatedString.Builder.appendWithHighlight(
internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
val elements = mutableListOf<ReaderElement>()
val sentences = mutableListOf<ReadAloudSentence>()
var pendingPauseBeforeMillis = 0L
fun addTextSentences(
itemIndex: Int,
@@ -670,17 +668,27 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
pauseBeforeMillis: Long = 0,
pauseAfterMillis: Long = 0,
) {
text.plainText().sentenceRanges().forEach { range ->
val sentenceText = text.plainText().substring(range.start, range.endExclusive).trim()
if (sentenceText.isNotEmpty()) {
val plainText = text.plainText()
if (plainText.isReadAloudPauseBreak()) {
pendingPauseBeforeMillis = max(pendingPauseBeforeMillis, StarBreakPauseMillis)
return
}
plainText.sentenceRanges().forEach { range ->
val sentenceText = plainText.substring(range.start, range.endExclusive).trim()
val spokenText = sentenceText.toReadAloudSpokenText()
if (spokenText.isNotEmpty()) {
val effectivePauseBefore = pauseBeforeMillis + pendingPauseBeforeMillis
pendingPauseBeforeMillis = 0L
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = range.start,
endExclusive = range.endExclusive,
text = sentenceText,
pauseBeforeMillis = pauseBeforeMillis,
pauseAfterMillis = pauseAfterMillis,
spokenText = spokenText,
pauseBeforeMillis = effectivePauseBefore,
pauseAfterMillis = pauseAfterMillis + range.pauseAfterMillis,
)
}
}
@@ -698,9 +706,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
start = 0,
endExclusive = section.title!!.length,
text = section.title!!,
pauseBeforeMillis = HeadingPauseBeforeMillis,
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis,
)
pendingPauseBeforeMillis = 0L
}
section.readableBlocks().forEach { block ->
val itemIndex = elements.size
@@ -756,11 +765,22 @@ internal sealed interface ReaderElement {
private data class ReaderSentenceRange(
val start: Int,
val endExclusive: Int,
val pauseAfterMillis: Long = 0L,
)
private fun Fb2Text.plainText(): String =
spans.joinToString(separator = "") { it.text }
private fun String.isReadAloudPauseBreak(): Boolean {
val compact = trim()
return compact.length >= 3 && compact.all { it == '*' || it.isWhitespace() }
}
private fun String.toReadAloudSpokenText(): String =
replace(Regex("\\.{2,}"), ".")
.replace('…', '.')
.trim()
private fun String.sentenceRanges(): List<ReaderSentenceRange> {
val ranges = mutableListOf<ReaderSentenceRange>()
var start = 0
@@ -773,9 +793,15 @@ private fun String.sentenceRanges(): List<ReaderSentenceRange> {
var index = start
while (index < length) {
if (this[index].isSentenceTerminator()) {
var end = index + 1
val terminatorEnd = sentenceTerminatorEnd(index)
val pauseAfterMillis = if (hasReadAloudEllipsisAt(index, terminatorEnd)) {
EllipsisPauseAfterMillis
} else {
0L
}
var end = terminatorEnd
while (end < length && this[end] in "\"'»”’)]}") end += 1
ranges += ReaderSentenceRange(start, end)
ranges += ReaderSentenceRange(start, end, pauseAfterMillis)
start = end
skipLeadingWhitespace()
index = start
@@ -789,11 +815,23 @@ private fun String.sentenceRanges(): List<ReaderSentenceRange> {
return ranges
}
private fun String.sentenceTerminatorEnd(index: Int): Int {
if (this[index] != '.') return index + 1
var end = index + 1
while (end < length && this[end] == '.') end += 1
return end
}
private fun String.hasReadAloudEllipsisAt(index: Int, terminatorEnd: Int): Boolean =
this[index] == '…' || (this[index] == '.' && terminatorEnd - index >= 2)
private fun Char.isSentenceTerminator(): Boolean =
this == '.' || this == '!' || this == '?' || this == '…'
private const val HeadingPauseBeforeMillis = 1_000L
private const val HeadingPauseAfterMillis = 600L
private const val StarBreakPauseMillis = 1_200L
private const val EllipsisPauseAfterMillis = 350L
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section ->
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,6 +36,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -74,7 +77,9 @@ internal fun BookView(
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
@@ -249,11 +254,24 @@ internal fun BookView(
},
onBack = { ReadAloudPlatform.skip(-1) },
onForward = { ReadAloudPlatform.skip(1) },
onSettings = {
ReadAloudPlatform.refreshSettings()
readAloudSettingsVisible = true
},
)
}
}
}
}
if (readAloudSettingsVisible) {
ReadAloudSettingsDialog(
state = readAloudSettings,
onDismiss = { readAloudSettingsVisible = false },
onEngineSelected = { ReadAloudPlatform.selectEngine(it) },
onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
)
}
}
@Composable
@@ -372,6 +390,7 @@ private fun ReadAloudPanel(
onPlayStop: () -> Unit,
onBack: () -> Unit,
onForward: () -> Unit,
onSettings: () -> Unit,
) {
Surface(
tonalElevation = 3.dp,
@@ -396,9 +415,95 @@ private fun ReadAloudPanel(
IconButton(onClick = onForward) {
Icon(Icons.Filled.FastForward, contentDescription = "Next sentence")
}
IconButton(onClick = {}, enabled = false) {
IconButton(onClick = onSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Read aloud settings")
}
}
}
}
@Composable
private fun ReadAloudSettingsDialog(
state: ReadAloudSettingsState,
onDismiss: () -> Unit,
onEngineSelected: (String?) -> Unit,
onVoiceSelected: (String?) -> Unit,
) {
var engineMenuOpen by remember { mutableStateOf(false) }
var voiceMenuOpen by remember { mutableStateOf(false) }
val selectedEngineLabel = state.engines.firstOrNull { it.id == state.selectedEngineId }?.label ?: "System default"
val selectedVoiceLabel = state.voices.firstOrNull { it.id == state.selectedVoiceId }
?.let { "${it.label} (${it.localeTag})" }
?: "Auto Russian"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Read aloud") },
text = {
Column {
Text("TTS engine", style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { engineMenuOpen = true }) {
Text(selectedEngineLabel)
}
DropdownMenu(expanded = engineMenuOpen, onDismissRequest = { engineMenuOpen = false }) {
DropdownMenuItem(
text = { Text("System default") },
onClick = {
engineMenuOpen = false
onEngineSelected(null)
},
)
state.engines.forEach { engine ->
DropdownMenuItem(
text = { Text(engine.label) },
onClick = {
engineMenuOpen = false
onEngineSelected(engine.id)
},
)
}
}
}
Text("Offline voice", style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { voiceMenuOpen = true }, enabled = !state.loading && state.voices.isNotEmpty()) {
Text(selectedVoiceLabel)
}
DropdownMenu(expanded = voiceMenuOpen, onDismissRequest = { voiceMenuOpen = false }) {
DropdownMenuItem(
text = { Text("Auto Russian") },
onClick = {
voiceMenuOpen = false
onVoiceSelected(null)
},
)
state.voices.forEach { voice ->
DropdownMenuItem(
text = { Text("${voice.label} (${voice.localeTag})") },
onClick = {
voiceMenuOpen = false
onVoiceSelected(voice.id)
},
)
}
}
}
if (state.loading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text("Loading voices...", modifier = Modifier.padding(start = 12.dp))
}
}
state.message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
}
},
)
}