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

@@ -9,6 +9,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -18,6 +18,7 @@ import android.speech.tts.UtteranceProgressListener
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -29,6 +30,7 @@ import kotlinx.coroutines.launch
actual object ReadAloudPlatform {
actual val isSupported: Boolean = true
actual val state: StateFlow<ReadAloudState> = AndroidReadAloudEngine.state
actual val settingsState: StateFlow<ReadAloudSettingsState> = AndroidReadAloudEngine.settingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) {
val context = androidAppContext() ?: return
@@ -49,6 +51,21 @@ actual object ReadAloudPlatform {
actual fun skip(delta: Int) {
AndroidReadAloudEngine.skip(delta)
}
actual fun refreshSettings() {
val context = androidAppContext() ?: return
AndroidReadAloudEngine.refreshSettings(context)
}
actual fun selectEngine(engineId: String?) {
val context = androidAppContext() ?: return
AndroidReadAloudEngine.selectEngine(context, engineId)
}
actual fun selectVoice(voiceId: String?) {
val context = androidAppContext() ?: return
AndroidReadAloudEngine.selectVoice(context, voiceId)
}
}
private object AndroidReadAloudEngine {
@@ -56,12 +73,22 @@ private object AndroidReadAloudEngine {
private const val BeforePausePrefix = "read-aloud-before-"
private const val SpeakPrefix = "read-aloud-speak-"
private const val AfterPausePrefix = "read-aloud-after-"
private const val SettingsPrefs = "read_aloud_tts"
private const val SelectedEngineKey = "selected_engine"
private const val EngineChoiceMadeKey = "engine_choice_made"
private const val SelectedVoiceKey = "selected_voice"
private val RussianLocale = Locale.forLanguageTag("ru-RU")
private val mutableState = MutableStateFlow(ReadAloudState())
val state: StateFlow<ReadAloudState> = mutableState
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
private var tts: TextToSpeech? = null
private var ttsReady = false
private var ttsEngineId: String? = null
private var engineProbe: TextToSpeech? = null
private var voiceProbe: TextToSpeech? = null
private var shouldSpeakWhenReady = false
private var bookTitle: String = ""
private var sentences: List<ReadAloudSentence> = emptyList()
@@ -110,34 +137,122 @@ private object AndroidReadAloudEngine {
}
}
private fun ensureTts(context: Context) {
if (tts != null) return
tts = TextToSpeech(context) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) {
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit
override fun onDone(utteranceId: String?) {
handleUtteranceDone(utteranceId)
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
stop()
}
override fun onError(utteranceId: String?, errorCode: Int) {
stop()
}
})
if (shouldSpeakWhenReady) {
shouldSpeakWhenReady = false
speakCurrent()
}
} else {
stop()
fun refreshSettings(context: Context) {
val appContext = context.applicationContext
mutableSettingsState.value = mutableSettingsState.value.copy(loading = true, message = null)
engineProbe?.shutdown()
var probe: TextToSpeech? = null
probe = TextToSpeech(appContext) { status ->
if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false,
message = "Could not load Android TTS engines.",
)
probe?.shutdown()
if (engineProbe === probe) engineProbe = null
return@TextToSpeech
}
val engines = probe?.engines.orEmpty()
.map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
.sortedBy { it.label.lowercase() }
var selectedEngine = selectedEngineId(appContext).takeIf { saved ->
saved != null && engines.any { it.id == saved }
}
if (selectedEngine == null && !appContext.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
if (selectedEngine != null) {
appContext.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
}
}
mutableSettingsState.value = mutableSettingsState.value.copy(
engines = engines,
selectedEngineId = selectedEngine,
)
probe?.shutdown()
if (engineProbe === probe) engineProbe = null
refreshVoices(appContext, selectedEngine)
}
engineProbe = probe
}
fun selectEngine(context: Context, engineId: String?) {
val appContext = context.applicationContext
appContext.readAloudPrefs()
.edit()
.putBoolean(EngineChoiceMadeKey, true)
.putStringOrRemove(SelectedEngineKey, engineId)
.remove(SelectedVoiceKey)
.apply()
resetTts()
mutableSettingsState.value = mutableSettingsState.value.copy(
selectedEngineId = engineId,
selectedVoiceId = null,
voices = emptyList(),
loading = true,
message = null,
)
refreshVoices(appContext, engineId)
}
fun selectVoice(context: Context, voiceId: String?) {
val appContext = context.applicationContext
appContext.readAloudPrefs()
.edit()
.putStringOrRemove(SelectedVoiceKey, voiceId)
.apply()
mutableSettingsState.value = mutableSettingsState.value.copy(selectedVoiceId = voiceId)
applyTtsConfiguration(appContext)
if (mutableState.value.playing && ttsReady) {
speakCurrent()
}
}
private fun ensureTts(context: Context) {
val appContext = context.applicationContext
val selectedEngine = selectedEngineId(appContext)
if (tts != null && ttsEngineId == selectedEngine) return
resetTts()
ttsEngineId = selectedEngine
tts = if (selectedEngine == null) {
TextToSpeech(appContext) { status ->
handleTtsInit(appContext, status)
}
} else {
TextToSpeech(
appContext,
{ status -> handleTtsInit(appContext, status) },
selectedEngine,
)
}
}
private fun handleTtsInit(context: Context, status: Int) {
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) {
applyTtsConfiguration(context)
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit
override fun onDone(utteranceId: String?) {
handleUtteranceDone(utteranceId)
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
stop()
}
override fun onError(utteranceId: String?, errorCode: Int) {
stop()
}
})
if (shouldSpeakWhenReady) {
shouldSpeakWhenReady = false
speakCurrent()
}
} else {
stop()
}
}
@@ -156,7 +271,7 @@ private object AndroidReadAloudEngine {
} else {
TextToSpeech.QUEUE_FLUSH
}
tts?.speak(sentence.text, queueMode, params, "$SpeakPrefix$currentIndex")
tts?.speak(sentence.spokenText, queueMode, params, "$SpeakPrefix$currentIndex")
if (sentence.pauseAfterMillis > 0) {
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
}
@@ -182,8 +297,107 @@ private object AndroidReadAloudEngine {
stop()
}
}
private fun refreshVoices(context: Context, engineId: String?) {
voiceProbe?.shutdown()
var probe: TextToSpeech? = null
probe = if (engineId == null) {
TextToSpeech(context) { status ->
handleVoiceProbeInit(context, probe, status)
}
} else {
TextToSpeech(
context,
{ status -> handleVoiceProbeInit(context, probe, status) },
engineId,
)
}
voiceProbe = probe
}
private fun handleVoiceProbeInit(context: Context, probe: TextToSpeech?, status: Int) {
if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false,
message = "Could not load voices for the selected TTS engine.",
)
probe?.shutdown()
if (voiceProbe === probe) voiceProbe = null
return
}
val voices = probe?.voices.orEmpty()
.mapNotNull { voice ->
val locale = voice.locale ?: return@mapNotNull null
ReadAloudVoiceOption(
id = voice.name,
label = voice.name,
localeTag = locale.toLanguageTag(),
networkRequired = voice.isNetworkConnectionRequired,
)
}
.filter { !it.networkRequired }
.sortedWith(
compareByDescending<ReadAloudVoiceOption> { it.localeTag.startsWith("ru", ignoreCase = true) }
.thenBy { it.localeTag }
.thenBy { it.label.lowercase() },
)
val savedVoice = selectedVoiceId(context).takeIf { saved ->
saved != null && voices.any { it.id == saved }
}
mutableSettingsState.value = mutableSettingsState.value.copy(
voices = voices,
selectedVoiceId = savedVoice,
loading = false,
message = if (voices.isEmpty()) "No offline voices reported by the selected TTS engine." else null,
)
if (savedVoice == null && selectedVoiceId(context) != null) {
context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply()
}
probe?.shutdown()
if (voiceProbe === probe) voiceProbe = null
}
private fun applyTtsConfiguration(context: Context) {
val selectedVoice = selectedVoiceId(context)
val voice = selectedVoice?.let { voiceName ->
tts?.voices?.firstOrNull { it.name == voiceName && !it.isNetworkConnectionRequired }
}
if (voice != null) {
tts?.voice = voice
} else {
tts?.setLanguage(RussianLocale)
}
}
private fun resetTts() {
shouldSpeakWhenReady = false
ttsReady = false
ttsEngineId = null
tts?.stop()
tts?.shutdown()
tts = null
}
private fun selectedEngineId(context: Context): String? =
context.readAloudPrefs().getString(SelectedEngineKey, null)
private fun selectedVoiceId(context: Context): String? =
context.readAloudPrefs().getString(SelectedVoiceKey, null)
private fun Context.readAloudPrefs() =
getSharedPreferences(SettingsPrefs, Context.MODE_PRIVATE)
private fun ReadAloudEngineOption.isRhVoice(): Boolean =
id.contains("rhvoice", ignoreCase = true) || label.contains("RHVoice", ignoreCase = true)
}
private fun android.content.SharedPreferences.Editor.putStringOrRemove(
key: String,
value: String?,
): android.content.SharedPreferences.Editor =
if (value == null) remove(key) else putString(key, value)
class ReadAloudService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -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")
}
},
)
}
@@ -0,0 +1,58 @@
package net.sergeych.toread
import kotlin.test.Test
import kotlin.test.assertEquals
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan
class ReadAloudContentPlanTest {
@Test
fun starBreakAddsPauseBeforeNextSpokenSentence() {
val plan = buildReaderContentPlan(
Fb2Book(
title = "Book",
sections = listOf(
Fb2Section(
blocks = listOf(
paragraph("Before."),
paragraph("* * *"),
paragraph("After."),
),
),
),
),
)
assertEquals(listOf("Before.", "After."), plan.sentences.map { it.text })
assertEquals(0L, plan.sentences[0].pauseBeforeMillis)
assertEquals(1_200L, plan.sentences[1].pauseBeforeMillis)
}
@Test
fun ellipsisIsCollapsedForSpeechAndAddsShortPause() {
val plan = buildReaderContentPlan(
Fb2Book(
title = "Book",
sections = listOf(
Fb2Section(blocks = listOf(paragraph("Wait... Go.. Stop… Done."))),
),
),
)
assertEquals(
listOf("Wait...", "Go..", "Stop…", "Done."),
plan.sentences.map { it.text },
)
assertEquals(
listOf("Wait.", "Go.", "Stop.", "Done."),
plan.sentences.map { it.spokenText },
)
assertEquals(listOf(350L, 350L, 350L, 0L), plan.sentences.map { it.pauseAfterMillis })
}
private fun paragraph(text: String): Fb2Block.Paragraph =
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
}
@@ -7,9 +7,14 @@ actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow<ReadAloudState> = mutableState
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
actual fun play() = Unit
actual fun stop() = Unit
actual fun skip(delta: Int) = Unit
actual fun refreshSettings() = Unit
actual fun selectEngine(engineId: String?) = Unit
actual fun selectVoice(voiceId: String?) = Unit
}
@@ -7,9 +7,14 @@ actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow<ReadAloudState> = mutableState
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
actual fun play() = Unit
actual fun stop() = Unit
actual fun skip(delta: Int) = Unit
actual fun refreshSettings() = Unit
actual fun selectEngine(engineId: String?) = Unit
actual fun selectVoice(voiceId: String?) = Unit
}