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 +}