diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index e2975bf..30eb86b 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -5,6 +5,9 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt
index 3ca927a..5cbac7b 100644
--- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt
+++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt
@@ -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? =
diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt
index e2a6137..494184c 100644
--- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt
@@ -31,6 +31,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher
private lateinit var allFilesAccessLauncher: ActivityResultLauncher
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher
+ private lateinit var notificationPermissionLauncher: ActivityResultLauncher
private var pendingDirectoryChoice: CompletableDeferred? = null
private var pendingExternalFileAccess: CompletableDeferred? = 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
diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt
new file mode 100644
index 0000000..5c00990
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt
@@ -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 = AndroidReadAloudEngine.state
+
+ actual fun prepare(bookTitle: String, sentences: List, 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 = mutableState
+
+ private var tts: TextToSpeech? = null
+ private var ttsReady = false
+ private var shouldSpeakWhenReady = false
+ private var bookTitle: String = ""
+ private var sentences: List = emptyList()
+ private var currentIndex: Int = 0
+
+ fun prepare(context: Context, title: String, queue: List, 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)
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt
new file mode 100644
index 0000000..7d8e02b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt
@@ -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
+
+ fun prepare(bookTitle: String, sentences: List, startIndex: Int)
+ fun play()
+ fun stop()
+ fun skip(delta: Int)
+}
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
index 24b2df9..e75e8c8 100644
--- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt
@@ -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(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()
+ val sentences = mutableListOf()
+
+ 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,
+ val sentences: List,
+) {
+ 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 {
+ val ranges = mutableListOf()
+ 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.flattenSections(depth: Int = 0): List =
flatMapIndexed { index, section ->
val fallback = "Section ${index + 1}"
diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
index cdbde20..e4ca890 100644
--- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
+++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt
@@ -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")
+ }
+ }
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt
new file mode 100644
index 0000000..31efd51
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt
@@ -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 = mutableState
+
+ actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit
+ actual fun play() = Unit
+ actual fun stop() = Unit
+ actual fun skip(delta: Int) = Unit
+}
diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt
new file mode 100644
index 0000000..31efd51
--- /dev/null
+++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt
@@ -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 = mutableState
+
+ actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit
+ actual fun play() = Unit
+ actual fun stop() = Unit
+ actual fun skip(delta: Int) = Unit
+}