Improve Android read aloud TTS handling
This commit is contained in:
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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user