Add Android read aloud support

This commit is contained in:
sergeych committed 2026-05-18 00:58:56 +03:00
1 parent 26343cb8a1
commit 11c8f7c6e4
9 files changed
+745 -107

No files matched your search

@@ -0,0 +1,29 @@
package net.sergeych.toread
import kotlinx.coroutines.flow.StateFlow
data class ReadAloudSentence(
val index: Int,
val itemIndex: Int,
val start: Int,
val endExclusive: Int,
val text: String,
val pauseBeforeMillis: Long = 0,
val pauseAfterMillis: Long = 0,
)
data class ReadAloudState(
val active: Boolean = false,
val playing: Boolean = false,
val sentenceIndex: Int? = null,
)
expect object ReadAloudPlatform {
val isSupported: Boolean
val state: StateFlow<ReadAloudState>
fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int)
fun play()
fun stop()
fun skip(delta: Int)
}
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -85,6 +84,8 @@ internal fun ContinuousBookReader(
stats: BookStats,
listState: LazyListState,
modifier: Modifier = Modifier,
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null,
onImageOpen: (ViewedBookImage) -> Unit = {},
) {
val hyphenation = remember { HyphenationRegistry() }
@@ -114,27 +115,78 @@ internal fun ContinuousBookReader(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book, onImageOpen = onImageOpen)
MetadataCard(book)
StatsCard(stats)
itemsIndexed(contentPlan.elements) { itemIndex, element ->
val highlightedRange = highlightedSentence
?.takeIf { it.itemIndex == itemIndex }
?.let { ReaderSentenceRange(it.start, it.endExclusive) }
when (element) {
ReaderElement.Cover -> Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book, onImageOpen = onImageOpen)
MetadataCard(book)
StatsCard(stats)
}
is ReaderElement.FixedSpacer -> Spacer(Modifier.height(element.heightDp.dp))
ReaderElement.SectionSeparator -> Spacer(
Modifier.height(2.dp)
.background(MaterialTheme.colorScheme.secondaryFixedDim)
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
)
is ReaderElement.SectionTitle -> {
val titleModifier = Modifier
.fillMaxWidth()
.then(
if (highlightedRange != null) {
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
} else {
Modifier
},
)
.padding(
top = if (element.depth == 0) 22.dp else 14.dp,
start = (element.depth * 12).dp,
bottom = 4.dp,
)
Text(
element.title,
style = when (element.depth) {
0 -> MaterialTheme.typography.headlineMedium
1 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium
},
fontWeight = FontWeight.Bold,
lineHeight = if (element.depth == 0) 36.sp else 28.sp,
modifier = titleModifier,
)
}
is ReaderElement.BookImage -> BookImage(
book = book,
image = element.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
onOpen = onImageOpen,
)
is ReaderElement.Paragraph -> ReaderText(
text = element.text,
language = book.language,
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),
)
is ReaderElement.Subtitle -> ReaderText(
text = element.text,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
highlightedRange = highlightedRange,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
)
}
}
item {
Spacer(Modifier.height(6.dp))
}
book.sections.forEachIndexed { index, section ->
sectionItems(
book = book,
section = section,
depth = 0,
keyPrefix = "section-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
item { Spacer(Modifier.height(22.dp)) }
}
}
@@ -166,81 +218,6 @@ private fun LazyListState.pageScrollDistance(): Float {
return viewportHeight.toFloat().coerceAtLeast(0f)
}
private fun LazyListScope.sectionItems(
book: Fb2Book,
section: Fb2Section,
depth: Int,
keyPrefix: String,
hyphenation: HyphenationRegistry,
onImageOpen: (ViewedBookImage) -> Unit,
) {
if( section.title.isNullOrBlank() ) {
item {
Spacer(Modifier.height(2.dp)
.background(MaterialTheme.colorScheme.secondaryFixedDim)
.fillMaxWidth().padding(vertical = 5.dp, horizontal = 4.dp)
)
}
}
else {
item(key = "$keyPrefix-title") {
Text(
section.title!!,
style = when (depth) {
0 -> MaterialTheme.typography.headlineMedium
1 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium
},
fontWeight = FontWeight.Bold,
lineHeight = if (depth == 0) 36.sp else 28.sp,
modifier = Modifier
.fillMaxWidth()
.padding(top = if (depth == 0) 22.dp else 14.dp, start = (depth * 12).dp, bottom = 4.dp),
)
}
}
items(section.readableBlocks()) { block ->
when (block) {
Fb2Block.EmptyLine -> Spacer(Modifier.height(16.dp))
is Fb2Block.Image -> BookImage(
book = book,
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
onOpen = onImageOpen,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
/* 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 = (depth * 8).dp + 6.dp, end = 0.dp),
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
)
}
}
section.sections.forEachIndexed { index, child ->
sectionItems(
book = book,
section = child,
depth = depth + 1,
keyPrefix = "$keyPrefix-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
}
@Composable
private fun DetailsPane(
book: Fb2Book,
@@ -410,8 +387,10 @@ private fun ReaderText(
style: TextStyle,
textAlign: TextAlign,
modifier: Modifier = Modifier,
highlightedRange: ReaderSentenceRange? = null,
) {
val annotatedText = text.toAnnotatedString(language, hyphenation)
val highlightColor = MaterialTheme.colorScheme.secondaryContainer
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
val needsSoftHyphenPaintWorkaround = isDesktopPlatform()
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
@@ -530,8 +509,14 @@ private fun BookImage(
}
}
private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: HyphenationRegistry): AnnotatedString =
private fun Fb2Text.toAnnotatedString(
language: String?,
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
highlightColor: Color,
): AnnotatedString =
buildAnnotatedString {
var plainOffset = 0
spans.forEach { span ->
val spanStyle = SpanStyle(
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
@@ -549,11 +534,182 @@ private fun Fb2Text.toAnnotatedString(language: String?, hyphenation: Hyphenatio
},
)
withStyle(spanStyle) {
append(hyphenation.hyphenate(span.text, language))
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
}
plainOffset += span.text.length
}
}
private fun AnnotatedString.Builder.appendWithHighlight(
text: String,
plainOffset: Int,
highlightedRange: ReaderSentenceRange?,
highlightColor: Color,
language: String?,
hyphenation: HyphenationRegistry,
) {
if (highlightedRange == null) {
append(hyphenation.hyphenate(text, language))
return
}
var cursor = 0
while (cursor < text.length) {
val absolute = plainOffset + cursor
val inHighlight = absolute >= highlightedRange.start && absolute < highlightedRange.endExclusive
val nextBoundary = if (inHighlight) {
min(text.length, highlightedRange.endExclusive - plainOffset)
} else {
min(text.length, max(0, highlightedRange.start - plainOffset))
.takeIf { it > cursor } ?: text.length
}
val part = text.substring(cursor, nextBoundary)
if (inHighlight) {
withStyle(SpanStyle(background = highlightColor)) {
append(hyphenation.hyphenate(part, language))
}
} else {
append(hyphenation.hyphenate(part, language))
}
cursor = nextBoundary
}
}
internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
val elements = mutableListOf<ReaderElement>()
val sentences = mutableListOf<ReadAloudSentence>()
fun addTextSentences(
itemIndex: Int,
text: Fb2Text,
pauseBeforeMillis: Long = 0,
pauseAfterMillis: Long = 0,
) {
text.plainText().sentenceRanges().forEach { range ->
val sentenceText = text.plainText().substring(range.start, range.endExclusive).trim()
if (sentenceText.isNotEmpty()) {
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = range.start,
endExclusive = range.endExclusive,
text = sentenceText,
pauseBeforeMillis = pauseBeforeMillis,
pauseAfterMillis = pauseAfterMillis,
)
}
}
}
fun addSection(section: Fb2Section, depth: Int) {
if (section.title.isNullOrBlank()) {
elements += ReaderElement.SectionSeparator
} else {
val itemIndex = elements.size
elements += ReaderElement.SectionTitle(section.title!!, depth)
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = 0,
endExclusive = section.title!!.length,
text = section.title!!,
pauseBeforeMillis = HeadingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis,
)
}
section.readableBlocks().forEach { block ->
val itemIndex = elements.size
when (block) {
Fb2Block.EmptyLine -> elements += ReaderElement.FixedSpacer(16)
is Fb2Block.Image -> elements += ReaderElement.BookImage(block.image)
is Fb2Block.Paragraph -> {
addTextSentences(itemIndex, block.content)
elements += ReaderElement.Paragraph(block.content, depth)
}
is Fb2Block.Subtitle -> {
addTextSentences(
itemIndex = itemIndex,
text = block.content,
pauseBeforeMillis = HeadingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis,
)
elements += ReaderElement.Subtitle(block.content)
}
}
}
section.sections.forEach { addSection(it, depth + 1) }
}
elements += ReaderElement.Cover
elements += ReaderElement.FixedSpacer(6)
book.sections.forEach { addSection(it, 0) }
elements += ReaderElement.FixedSpacer(22)
return ReaderContentPlan(elements, sentences)
}
internal data class ReaderContentPlan(
val elements: List<ReaderElement>,
val sentences: List<ReadAloudSentence>,
) {
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
?: sentences.lastOrNull()?.index
?: 0
}
internal sealed interface ReaderElement {
data object Cover : ReaderElement
data class FixedSpacer(val heightDp: Int) : ReaderElement
data object SectionSeparator : ReaderElement
data class SectionTitle(val title: String, val depth: Int) : ReaderElement
data class BookImage(val image: Fb2ImageRef) : ReaderElement
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
data class Subtitle(val text: Fb2Text) : ReaderElement
}
private data class ReaderSentenceRange(
val start: Int,
val endExclusive: Int,
)
private fun Fb2Text.plainText(): String =
spans.joinToString(separator = "") { it.text }
private fun String.sentenceRanges(): List<ReaderSentenceRange> {
val ranges = mutableListOf<ReaderSentenceRange>()
var start = 0
fun skipLeadingWhitespace() {
while (start < length && this[start].isWhitespace()) start += 1
}
skipLeadingWhitespace()
var index = start
while (index < length) {
if (this[index].isSentenceTerminator()) {
var end = index + 1
while (end < length && this[end] in "\"'»”’)]}") end += 1
ranges += ReaderSentenceRange(start, end)
start = end
skipLeadingWhitespace()
index = start
} else {
index += 1
}
}
if (start < length) {
ranges += ReaderSentenceRange(start, length)
}
return ranges
}
private fun Char.isSentenceTerminator(): Boolean =
this == '.' || this == '!' || this == '?' || this == '…'
private const val HeadingPauseBeforeMillis = 1_000L
private const val HeadingPauseAfterMillis = 600L
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section ->
val fallback = "Section ${index + 1}"
@@ -2,6 +2,8 @@ package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
@@ -12,8 +14,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,9 +32,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -57,14 +67,21 @@ internal fun BookView(
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
val contentPlan = remember(book) { buildReaderContentPlan(book) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val highlightedSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudPanelVisible && readAloudState.active }
fun showMessage(message: String) {
scope.launch {
@@ -88,6 +105,12 @@ internal fun BookView(
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
}
DisposableEffect(fileId) {
onDispose {
ReadAloudPlatform.stop()
}
}
LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position ->
listState.scrollToItem(position.itemIndex, position.scrollOffset)
@@ -118,6 +141,15 @@ internal fun BookView(
}
}
LaunchedEffect(readAloudState.sentenceIndex) {
val itemIndex = highlightedSentence?.itemIndex ?: return@LaunchedEffect
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0))
val visibleItems = listState.layoutInfo.visibleItemsInfo
if (visibleItems.none { it.index == itemIndex }) {
listState.animateScrollToItem(itemIndex)
}
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -157,6 +189,13 @@ internal fun BookView(
showMessage(if (opened) "Opened file location." else "Could not open file location.")
}
},
showReadAloudAction = showReadAloudAction,
onReadAloud = {
val startIndex = contentPlan.sentenceIndexAtOrAfterItem(listState.firstVisibleItemIndex)
ReadAloudPlatform.prepare(book.title, contentPlan.sentences, startIndex)
readAloudPanelVisible = true
ReadAloudPlatform.play()
},
onDelete = {
scope.launch {
val result = deleteLibraryBook(fileId, book.title)
@@ -187,13 +226,32 @@ internal fun BookView(
.padding(it)
.background(readerBackground()),
) {
ContinuousBookReader(
book = book,
stats = stats,
modifier = Modifier.fillMaxSize(),
listState = listState,
onImageOpen = onImageOpen,
)
Column(Modifier.fillMaxSize()) {
ContinuousBookReader(
book = book,
stats = stats,
modifier = Modifier.weight(1f),
listState = listState,
contentPlan = contentPlan,
highlightedSentence = highlightedSentence,
onImageOpen = onImageOpen,
)
if (readAloudPanelVisible && readAloudState.active) {
ReadAloudPanel(
playing = readAloudState.playing,
onPlayStop = {
if (readAloudState.playing) {
ReadAloudPlatform.stop()
readAloudPanelVisible = false
} else {
ReadAloudPlatform.play()
}
},
onBack = { ReadAloudPlatform.skip(-1) },
onForward = { ReadAloudPlatform.skip(1) },
)
}
}
}
}
}
@@ -210,6 +268,8 @@ private fun CompactReaderTopBar(
onShare: () -> Unit,
showViewFileAction: Boolean,
onViewFile: () -> Unit,
showReadAloudAction: Boolean,
onReadAloud: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
@@ -232,8 +292,10 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme")
}
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
if (showReadAloudAction) {
IconButton(onClick = onReadAloud) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
@@ -303,3 +365,40 @@ private fun CompactReaderTopBar(
}
}
}
@Composable
private fun ReadAloudPanel(
playing: Boolean,
onPlayStop: () -> Unit,
onBack: () -> Unit,
onForward: () -> Unit,
) {
Surface(
tonalElevation = 3.dp,
shadowElevation = 4.dp,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.Filled.Replay, contentDescription = "Previous sentence")
}
IconButton(onClick = onPlayStop) {
Icon(
if (playing) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Stop reading" else "Start reading",
)
}
IconButton(onClick = onForward) {
Icon(Icons.Filled.FastForward, contentDescription = "Next sentence")
}
IconButton(onClick = {}, enabled = false) {
Icon(Icons.Filled.Settings, contentDescription = "Read aloud settings")
}
}
}
}