Add Android read aloud support

This commit is contained in:
Sergey Chernov 2026-05-18 00:58:56 +03:00
parent 26343cb8a1
commit 11c8f7c6e4
9 changed files with 745 additions and 107 deletions

View File

@ -5,6 +5,9 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
@ -52,6 +55,10 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/image_clipboard_paths"/>
</provider>
<service
android:name=".ReadAloudService"
android:exported="false"
android:foregroundServiceType="mediaPlayback"/>
</application>
</manifest>

View File

@ -52,6 +52,9 @@ fun initToreadPlatform(context: Context, chooser: AndroidLibraryDirectoryChooser
directoryChooser = chooser
}
internal fun androidAppContext(): Context? =
if (::appContext.isInitialized) appContext else null
actual fun loadDefaultBookBytes(): ByteArray? = null
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =

View File

@ -31,6 +31,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
private lateinit var allFilesAccessLauncher: ActivityResultLauncher<Intent>
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher<String>
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
private var pendingDirectoryChoice: CompletableDeferred<String?>? = null
private var pendingExternalFileAccess: CompletableDeferred<Boolean>? = null
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
pendingExternalFileAccess?.complete(granted)
pendingExternalFileAccess = null
}
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, true)
initToreadPlatform(this, this)
@ -62,6 +64,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
App()
}
}
requestNotificationPermissionIfNeeded()
}
override suspend fun chooseDirectory(): String? {
@ -166,6 +169,12 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
allFilesAccessLauncher.launch(settingsIntent)
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
private fun downloadsPath(): String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: Environment.getExternalStorageDirectory().absolutePath

View File

@ -0,0 +1,305 @@
package net.sergeych.toread
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
actual object ReadAloudPlatform {
actual val isSupported: Boolean = true
actual val state: StateFlow<ReadAloudState> = AndroidReadAloudEngine.state
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) {
val context = androidAppContext() ?: return
AndroidReadAloudEngine.prepare(context, bookTitle, sentences, startIndex)
ReadAloudService.start(context)
}
actual fun play() {
val context = androidAppContext() ?: return
ReadAloudService.start(context)
AndroidReadAloudEngine.play(context)
}
actual fun stop() {
AndroidReadAloudEngine.stop()
}
actual fun skip(delta: Int) {
AndroidReadAloudEngine.skip(delta)
}
}
private object AndroidReadAloudEngine {
private const val UtterancePrefix = "read-aloud-"
private const val BeforePausePrefix = "read-aloud-before-"
private const val SpeakPrefix = "read-aloud-speak-"
private const val AfterPausePrefix = "read-aloud-after-"
private val mutableState = MutableStateFlow(ReadAloudState())
val state: StateFlow<ReadAloudState> = mutableState
private var tts: TextToSpeech? = null
private var ttsReady = false
private var shouldSpeakWhenReady = false
private var bookTitle: String = ""
private var sentences: List<ReadAloudSentence> = emptyList()
private var currentIndex: Int = 0
fun prepare(context: Context, title: String, queue: List<ReadAloudSentence>, startIndex: Int) {
bookTitle = title
sentences = queue
currentIndex = startIndex.coerceIn(queue.indices.takeIf { queue.isNotEmpty() } ?: 0..0)
mutableState.value = ReadAloudState(
active = queue.isNotEmpty(),
playing = false,
sentenceIndex = queue.getOrNull(currentIndex)?.index,
)
ensureTts(context.applicationContext)
}
fun play(context: Context) {
if (sentences.isEmpty()) return
ensureTts(context.applicationContext)
if (!ttsReady) {
shouldSpeakWhenReady = true
mutableState.value = mutableState.value.copy(active = true, playing = true, sentenceIndex = currentIndex)
return
}
speakCurrent()
}
fun stop() {
shouldSpeakWhenReady = false
tts?.stop()
mutableState.value = ReadAloudState()
}
fun skip(delta: Int) {
if (sentences.isEmpty()) return
val wasPlaying = mutableState.value.playing
currentIndex = (currentIndex + delta).coerceIn(sentences.indices)
mutableState.value = mutableState.value.copy(
active = true,
playing = wasPlaying,
sentenceIndex = currentIndex,
)
if (wasPlaying && ttsReady) {
speakCurrent()
}
}
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()
}
}
}
private fun speakCurrent() {
val sentence = sentences.getOrNull(currentIndex) ?: run {
stop()
return
}
mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index)
val params = Bundle().apply {
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "$SpeakPrefix$currentIndex")
}
val queueMode = if (sentence.pauseBeforeMillis > 0) {
tts?.playSilentUtterance(sentence.pauseBeforeMillis, TextToSpeech.QUEUE_FLUSH, "$BeforePausePrefix$currentIndex")
TextToSpeech.QUEUE_ADD
} else {
TextToSpeech.QUEUE_FLUSH
}
tts?.speak(sentence.text, queueMode, params, "$SpeakPrefix$currentIndex")
if (sentence.pauseAfterMillis > 0) {
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
}
}
private fun handleUtteranceDone(utteranceId: String?) {
if (!mutableState.value.playing) return
val speakIndex = utteranceId?.removePrefix(SpeakPrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
val afterIndex = utteranceId?.removePrefix(AfterPausePrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
val finishedIndex = afterIndex ?: speakIndex ?: return
if (finishedIndex != currentIndex) return
val currentSentence = sentences.getOrNull(currentIndex) ?: run {
stop()
return
}
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) {
return
}
if (currentIndex < sentences.lastIndex) {
currentIndex += 1
speakCurrent()
} else {
stop()
}
}
}
class ReadAloudService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun onCreate() {
super.onCreate()
createNotificationChannel()
scope.launch {
AndroidReadAloudEngine.state.collect { state ->
if (state.active) {
updateNotification(state)
} else {
stopForegroundCompat()
stopSelf()
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NotificationId, buildNotification(AndroidReadAloudEngine.state.value))
when (intent?.action) {
ActionPlay -> AndroidReadAloudEngine.play(applicationContext)
ActionStop -> AndroidReadAloudEngine.stop()
ActionBack -> AndroidReadAloudEngine.skip(-1)
ActionForward -> AndroidReadAloudEngine.skip(1)
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun buildNotification(state: ReadAloudState): Notification {
val openIntent = packageManager.getLaunchIntentForPackage(packageName)
val contentIntent = PendingIntent.getActivity(
this,
0,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val playStopAction = if (state.playing) ActionStop else ActionPlay
val playStopTitle = if (state.playing) "Stop" else "Play"
val playStopIcon = if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
return NotificationCompat.Builder(this, ChannelId)
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentTitle("Read aloud")
.setContentText("Reading in the background")
.setContentIntent(contentIntent)
.setOngoing(state.playing)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(android.R.drawable.ic_media_previous, "Previous", serviceIntent(ActionBack))
.addAction(playStopIcon, playStopTitle, serviceIntent(playStopAction))
.addAction(android.R.drawable.ic_media_next, "Next", serviceIntent(ActionForward))
.build()
}
private fun serviceIntent(action: String): PendingIntent =
PendingIntent.getService(
this,
action.hashCode(),
Intent(this, ReadAloudService::class.java).setAction(action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
@SuppressLint("MissingPermission")
private fun updateNotification(state: ReadAloudState) {
if (!hasNotificationPermission()) return
runCatching {
NotificationManagerCompat.from(this)
.notify(NotificationId, buildNotification(state))
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
ChannelId,
"Read aloud",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun hasNotificationPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
companion object {
private const val ChannelId = "read_aloud"
private const val NotificationId = 2401
private const val ActionPlay = "net.sergeych.toread.READ_ALOUD_PLAY"
private const val ActionStop = "net.sergeych.toread.READ_ALOUD_STOP"
private const val ActionBack = "net.sergeych.toread.READ_ALOUD_BACK"
private const val ActionForward = "net.sergeych.toread.READ_ALOUD_FORWARD"
fun start(context: Context) {
val intent = Intent(context, ReadAloudService::class.java)
ContextCompat.startForegroundService(context, intent)
}
}
}

View File

@ -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)
}

View File

@ -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)) {
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)
}
}
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,
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,
)
}
item { Spacer(Modifier.height(22.dp)) }
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),
)
}
}
}
}
@ -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}"

View File

@ -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()),
) {
Column(Modifier.fillMaxSize()) {
ContinuousBookReader(
book = book,
stats = stats,
modifier = Modifier.fillMaxSize(),
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,9 +292,11 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme")
}
IconButton(onClick = { }) {
if (showReadAloudAction) {
IconButton(onClick = onReadAloud) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
@ -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")
}
}
}
}

View File

@ -0,0 +1,15 @@
package net.sergeych.toread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow<ReadAloudState> = mutableState
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
}

View File

@ -0,0 +1,15 @@
package net.sergeych.toread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
actual object ReadAloudPlatform {
actual val isSupported: Boolean = false
private val mutableState = MutableStateFlow(ReadAloudState())
actual val state: StateFlow<ReadAloudState> = mutableState
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
}