TTS improvements

This commit is contained in:
sergeych committed 2026-10-13 01:43:22 +04:00
1 parent 9dbe30b20c
commit a2f417d147
11 files changed
+430 -108

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.1" val appVersionName = "1.1"
val appVersionCode = 9 val appVersionCode = 10
val appVersionDisplay = "$appVersionName.$appVersionCode" val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins { plugins {
@@ -485,6 +485,18 @@ actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispa
} }
} }
actual suspend fun loadReaderPagingOnly(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderPagingOnlyFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderPagingOnly(enabled: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderPagingOnlyFlag, enabled.toString())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) { actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@@ -904,6 +916,7 @@ private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings" private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen" private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ReaderPagingOnlyFlag = "reader_paging_only"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter" private const val LibraryFilterFlag = "library_filter"
private const val AppLocaleTagFlag = "app_locale_tag" private const val AppLocaleTagFlag = "app_locale_tag"
@@ -15,6 +15,7 @@ import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -72,7 +73,6 @@ actual object ReadAloudPlatform {
} }
private object AndroidReadAloudEngine { private object AndroidReadAloudEngine {
private const val UtterancePrefix = "read-aloud-"
private const val BeforePausePrefix = "read-aloud-before-" private const val BeforePausePrefix = "read-aloud-before-"
private const val SpeakPrefix = "read-aloud-speak-" private const val SpeakPrefix = "read-aloud-speak-"
private const val AfterPausePrefix = "read-aloud-after-" private const val AfterPausePrefix = "read-aloud-after-"
@@ -97,13 +97,27 @@ private object AndroidReadAloudEngine {
private var ttsEngineId: String? = null private var ttsEngineId: String? = null
private var engineProbe: TextToSpeech? = null private var engineProbe: TextToSpeech? = null
private var voiceProbe: TextToSpeech? = null private var voiceProbe: TextToSpeech? = null
private var ttsGeneration = 0L
private var engineProbeGeneration = 0L
private var voiceProbeGeneration = 0L
private var shouldSpeakWhenReady = false private var shouldSpeakWhenReady = false
private var fileId: String? = null private var fileId: String? = null
private var bookTitle: String = "" private var bookTitle: String = ""
private var sentences: List<ReadAloudSentence> = emptyList() private var sentences: List<ReadAloudSentence> = emptyList()
private var currentIndex: Int = 0 private var currentIndex: Int = 0
private var playbackGeneration = 0L
private var blockNumber = 0L
private var activeUtteranceId: String? = null
private var blocks: Iterator<ReadAloudBlock>? = null
private val queuedBlocks = linkedMapOf<String, ReadAloudBlock>()
private fun beforeId(id: String) = "$BeforePausePrefix$id"
private fun afterId(id: String) = "$AfterPausePrefix$id"
@Synchronized
fun prepare(context: Context, fileId: String, title: String, queue: List<ReadAloudSentence>, startIndex: Int) { fun prepare(context: Context, fileId: String, title: String, queue: List<ReadAloudSentence>, startIndex: Int) {
shouldSpeakWhenReady = false
cancelQueue()
this.fileId = fileId this.fileId = fileId
bookTitle = title bookTitle = title
sentences = queue sentences = queue
@@ -116,6 +130,7 @@ private object AndroidReadAloudEngine {
ensureTts(context.applicationContext) ensureTts(context.applicationContext)
} }
@Synchronized
fun play(context: Context) { fun play(context: Context) {
if (sentences.isEmpty()) return if (sentences.isEmpty()) return
ensureTts(context.applicationContext) ensureTts(context.applicationContext)
@@ -124,6 +139,7 @@ private object AndroidReadAloudEngine {
mutableState.value = mutableState.value.copy( mutableState.value = mutableState.value.copy(
active = true, active = true,
playing = true, playing = true,
buffering = true,
sentenceIndex = sentences.getOrNull(currentIndex)?.index, sentenceIndex = sentences.getOrNull(currentIndex)?.index,
) )
return return
@@ -131,12 +147,14 @@ private object AndroidReadAloudEngine {
speakCurrent() speakCurrent()
} }
@Synchronized
fun stop() { fun stop() {
shouldSpeakWhenReady = false shouldSpeakWhenReady = false
tts?.stop() cancelQueue()
mutableState.value = ReadAloudState() mutableState.value = ReadAloudState()
} }
@Synchronized
fun skip(delta: Int) { fun skip(delta: Int) {
if (sentences.isEmpty()) return if (sentences.isEmpty()) return
val wasPlaying = mutableState.value.playing val wasPlaying = mutableState.value.playing
@@ -153,51 +171,53 @@ private object AndroidReadAloudEngine {
} }
} }
@Synchronized
fun refreshSettings(context: Context) { fun refreshSettings(context: Context) {
val appContext = context.applicationContext val appContext = context.applicationContext
val textReplacements = userTextReplacements(appContext)
mutableSettingsState.value = mutableSettingsState.value.copy( mutableSettingsState.value = mutableSettingsState.value.copy(
textReplacements = textReplacements, textReplacements = userTextReplacements(appContext),
loading = true, loading = true,
message = null, message = null,
) )
val generation = ++engineProbeGeneration
engineProbe?.shutdown() engineProbe?.shutdown()
var probe: TextToSpeech? = null engineProbe = TextToSpeech(appContext) { status ->
probe = TextToSpeech(appContext) { status -> // Dispatch even if onInit fires synchronously inside the constructor.
if (status != TextToSpeech.SUCCESS) { scope.launch(Dispatchers.Main) { handleEngineProbeInit(appContext, generation, status) }
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false,
message = strings.couldNotLoadAndroidTtsEngines,
)
probe?.shutdown()
if (engineProbe === probe) engineProbe = null
return@TextToSpeech
}
val engines = probe?.engines.orEmpty()
.map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
.sortedBy { it.label.lowercase() }
var selectedEngine = selectedEngineId(appContext).takeIf { saved ->
saved != null && engines.any { it.id == saved }
}
if (selectedEngine == null && !appContext.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
if (selectedEngine != null) {
appContext.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
}
}
mutableSettingsState.value = mutableSettingsState.value.copy(
engines = engines,
selectedEngineId = selectedEngine,
textReplacements = textReplacements,
)
probe?.shutdown()
if (engineProbe === probe) engineProbe = null
refreshVoices(appContext, selectedEngine)
} }
engineProbe = probe
} }
@Synchronized
private fun handleEngineProbeInit(context: Context, generation: Long, status: Int) {
if (generation != engineProbeGeneration) return
val probe = engineProbe ?: return
if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false, message = strings.couldNotLoadAndroidTtsEngines,
)
probe.shutdown()
engineProbe = null
return
}
val engines = probe.engines.orEmpty()
.map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
.sortedBy { it.label.lowercase() }
var selectedEngine = selectedEngineId(context)
if (selectedEngine == null && !context.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
if (selectedEngine != null) {
context.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
}
}
mutableSettingsState.value = mutableSettingsState.value.copy(
engines = engines, selectedEngineId = selectedEngine,
)
probe.shutdown()
engineProbe = null
refreshVoices(context, selectedEngine)
}
@Synchronized
fun selectEngine(context: Context, engineId: String?) { fun selectEngine(context: Context, engineId: String?) {
val appContext = context.applicationContext val appContext = context.applicationContext
appContext.readAloudPrefs() appContext.readAloudPrefs()
@@ -206,6 +226,7 @@ private object AndroidReadAloudEngine {
.putStringOrRemove(SelectedEngineKey, engineId) .putStringOrRemove(SelectedEngineKey, engineId)
.remove(SelectedVoiceKey) .remove(SelectedVoiceKey)
.apply() .apply()
val wasPlaying = mutableState.value.playing
resetTts() resetTts()
mutableSettingsState.value = mutableSettingsState.value.copy( mutableSettingsState.value = mutableSettingsState.value.copy(
selectedEngineId = engineId, selectedEngineId = engineId,
@@ -215,8 +236,10 @@ private object AndroidReadAloudEngine {
message = null, message = null,
) )
refreshVoices(appContext, engineId) refreshVoices(appContext, engineId)
if (wasPlaying) play(appContext)
} }
@Synchronized
fun selectVoice(context: Context, voiceId: String?) { fun selectVoice(context: Context, voiceId: String?) {
val appContext = context.applicationContext val appContext = context.applicationContext
appContext.readAloudPrefs() appContext.readAloudPrefs()
@@ -236,25 +259,35 @@ private object AndroidReadAloudEngine {
if (tts != null && ttsEngineId == selectedEngine) return if (tts != null && ttsEngineId == selectedEngine) return
resetTts() resetTts()
ttsEngineId = selectedEngine ttsEngineId = selectedEngine
val generation = ttsGeneration
tts = if (selectedEngine == null) { tts = if (selectedEngine == null) {
TextToSpeech(appContext) { status -> TextToSpeech(appContext) { status ->
handleTtsInit(appContext, status) scope.launch(Dispatchers.Main) { handleTtsInit(appContext, generation, status) }
} }
} else { } else {
TextToSpeech( TextToSpeech(
appContext, appContext,
{ status -> handleTtsInit(appContext, status) }, { status -> scope.launch(Dispatchers.Main) { handleTtsInit(appContext, generation, status) } },
selectedEngine, selectedEngine,
) )
} }
} }
private fun handleTtsInit(context: Context, status: Int) { @Synchronized
private fun handleTtsInit(context: Context, generation: Long, status: Int) {
if (generation != ttsGeneration) return
ttsReady = status == TextToSpeech.SUCCESS ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) { if (ttsReady) {
applyTtsConfiguration(context) applyTtsConfiguration(context)
Log.i("YereedTts", "Initialized requested engine=${ttsEngineId ?: "system default"}, voice=${tts?.voice?.name}")
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit override fun onStart(utteranceId: String?) {
handleUtteranceStart(utteranceId)
}
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
handleUtteranceRange(utteranceId, start)
}
override fun onDone(utteranceId: String?) { override fun onDone(utteranceId: String?) {
handleUtteranceDone(utteranceId) handleUtteranceDone(utteranceId)
@@ -262,11 +295,11 @@ private object AndroidReadAloudEngine {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) { override fun onError(utteranceId: String?) {
stop() handleUtteranceError(utteranceId)
} }
override fun onError(utteranceId: String?, errorCode: Int) { override fun onError(utteranceId: String?, errorCode: Int) {
stop() handleUtteranceError(utteranceId)
} }
}) })
if (shouldSpeakWhenReady) { if (shouldSpeakWhenReady) {
@@ -278,51 +311,94 @@ private object AndroidReadAloudEngine {
} }
} }
private fun cancelQueue() {
// Invalidate callbacks before stopping: a late event must not affect the new queue.
playbackGeneration++
blocks = null
activeUtteranceId = null
queuedBlocks.clear()
tts?.stop()
}
private fun speakCurrent() { private fun speakCurrent() {
val sentence = sentences.getOrNull(currentIndex) ?: run { val sentence = sentences.getOrNull(currentIndex) ?: run {
stop() stop()
return return
} }
cancelQueue()
mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index) mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index)
saveSentencePosition(sentence) saveSentencePosition(sentence)
val params = Bundle().apply { blocks = readAloudBlocks(
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "$SpeakPrefix$currentIndex") sentences, currentIndex, minOf(4_000, TextToSpeech.getMaxSpeechInputLength()),
} ).iterator()
val queueMode = if (sentence.pauseBeforeMillis > 0) { fillQueue()
tts?.playSilentUtterance(sentence.pauseBeforeMillis, TextToSpeech.QUEUE_FLUSH, "$BeforePausePrefix$currentIndex")
TextToSpeech.QUEUE_ADD
} else {
TextToSpeech.QUEUE_FLUSH
}
tts?.speak(sentence.spokenText, queueMode, params, "$SpeakPrefix$currentIndex")
if (sentence.pauseAfterMillis > 0) {
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
}
} }
private fun handleUtteranceDone(utteranceId: String?) { private fun fillQueue() {
if (!mutableState.value.playing) return val engine = tts ?: return
val speakIndex = utteranceId?.removePrefix(SpeakPrefix)?.takeIf { it != utteranceId }?.toIntOrNull() val remaining = blocks ?: return
val afterIndex = utteranceId?.removePrefix(AfterPausePrefix)?.takeIf { it != utteranceId }?.toIntOrNull() // Keep the current block and two successors submitted, before playback ends.
val finishedIndex = afterIndex ?: speakIndex ?: return while (queuedBlocks.size < 3 && remaining.hasNext()) {
if (finishedIndex != currentIndex) return val block = remaining.next()
val currentSentence = sentences.getOrNull(currentIndex) ?: run { val id = "$SpeakPrefix$playbackGeneration-${blockNumber++}"
stop() queuedBlocks[id] = block
return val beforeResult = if (block.pauseBeforeMillis > 0) {
} engine.playSilentUtterance(block.pauseBeforeMillis, TextToSpeech.QUEUE_ADD, beforeId(id))
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) { } else TextToSpeech.SUCCESS
sentences.getOrNull(currentIndex + 1)?.let { nextSentence -> val speechResult = engine.speak(block.text, TextToSpeech.QUEUE_ADD, Bundle(), id)
mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index) val afterResult = if (block.pauseAfterMillis > 0) {
saveSentencePosition(nextSentence) engine.playSilentUtterance(block.pauseAfterMillis, TextToSpeech.QUEUE_ADD, afterId(id))
} else TextToSpeech.SUCCESS
if (beforeResult == TextToSpeech.ERROR || speechResult == TextToSpeech.ERROR ||
afterResult == TextToSpeech.ERROR) {
stop()
return
} }
return
} }
if (currentIndex < sentences.lastIndex) { if (queuedBlocks.isEmpty()) stop()
currentIndex += 1 }
speakCurrent()
} else { private fun updatePosition(position: Int) {
stop() if (currentIndex == position && mutableState.value.sentenceIndex == sentences[position].index) return
currentIndex = position
val sentence = sentences[position]
mutableState.value = mutableState.value.copy(sentenceIndex = sentence.index)
saveSentencePosition(sentence)
}
@Synchronized
private fun handleUtteranceStart(utteranceId: String?) {
if (!mutableState.value.playing) return
val block = queuedBlocks[utteranceId] ?: return
activeUtteranceId = utteranceId
updatePosition(block.ranges.first().position)
}
@Synchronized
private fun handleUtteranceRange(utteranceId: String?, offset: Int) {
if (!mutableState.value.playing || utteranceId != activeUtteranceId) return
val block = queuedBlocks[utteranceId] ?: return
updatePosition(block.positionAt(offset))
}
@Synchronized
private fun handleUtteranceDone(utteranceId: String?) {
if (!mutableState.value.playing || utteranceId == null) return
val isAfter = utteranceId.startsWith(AfterPausePrefix)
val id = if (isAfter) utteranceId.removePrefix(AfterPausePrefix) else utteranceId
val block = queuedBlocks[id] ?: return
if (!isAfter) {
if (activeUtteranceId == id) updatePosition(block.ranges.last().position)
if (block.pauseAfterMillis > 0) return
} }
queuedBlocks.remove(id)
fillQueue()
}
@Synchronized
private fun handleUtteranceError(utteranceId: String?) {
val id = utteranceId?.removePrefix(BeforePausePrefix)?.removePrefix(AfterPausePrefix)
if (queuedBlocks.containsKey(id)) stop()
} }
fun notificationTitle(): String = bookTitle.ifBlank { strings.readAloud } fun notificationTitle(): String = bookTitle.ifBlank { strings.readAloud }
@@ -341,34 +417,28 @@ private object AndroidReadAloudEngine {
} }
private fun refreshVoices(context: Context, engineId: String?) { private fun refreshVoices(context: Context, engineId: String?) {
val generation = ++voiceProbeGeneration
voiceProbe?.shutdown() voiceProbe?.shutdown()
var probe: TextToSpeech? = null voiceProbe = TextToSpeech(context, { status ->
probe = if (engineId == null) { scope.launch(Dispatchers.Main) { handleVoiceProbeInit(context, generation, status) }
TextToSpeech(context) { status -> }, engineId)
handleVoiceProbeInit(context, probe, status)
}
} else {
TextToSpeech(
context,
{ status -> handleVoiceProbeInit(context, probe, status) },
engineId,
)
}
voiceProbe = probe
} }
private fun handleVoiceProbeInit(context: Context, probe: TextToSpeech?, status: Int) { @Synchronized
private fun handleVoiceProbeInit(context: Context, generation: Long, status: Int) {
if (generation != voiceProbeGeneration) return
val probe = voiceProbe ?: return
if (status != TextToSpeech.SUCCESS) { if (status != TextToSpeech.SUCCESS) {
mutableSettingsState.value = mutableSettingsState.value.copy( mutableSettingsState.value = mutableSettingsState.value.copy(
loading = false, loading = false,
message = strings.couldNotLoadSelectedTtsVoices, message = strings.couldNotLoadSelectedTtsVoices,
) )
probe?.shutdown() probe.shutdown()
if (voiceProbe === probe) voiceProbe = null if (voiceProbe === probe) voiceProbe = null
return return
} }
val voices = probe?.voices.orEmpty() val voices = probe.voices.orEmpty()
.mapNotNull { voice -> .mapNotNull { voice ->
val locale = voice.locale ?: return@mapNotNull null val locale = voice.locale ?: return@mapNotNull null
ReadAloudVoiceOption( ReadAloudVoiceOption(
@@ -396,7 +466,7 @@ private object AndroidReadAloudEngine {
if (savedVoice == null && selectedVoiceId(context) != null) { if (savedVoice == null && selectedVoiceId(context) != null) {
context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply() context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply()
} }
probe?.shutdown() probe.shutdown()
if (voiceProbe === probe) voiceProbe = null if (voiceProbe === probe) voiceProbe = null
} }
@@ -413,10 +483,11 @@ private object AndroidReadAloudEngine {
} }
private fun resetTts() { private fun resetTts() {
ttsGeneration++
shouldSpeakWhenReady = false shouldSpeakWhenReady = false
ttsReady = false ttsReady = false
ttsEngineId = null ttsEngineId = null
tts?.stop() cancelQueue()
tts?.shutdown() tts?.shutdown()
tts = null tts = null
} }
@@ -171,6 +171,10 @@ expect suspend fun loadReaderFullScreen(): Boolean
expect suspend fun saveReaderFullScreen(fullScreen: Boolean) expect suspend fun saveReaderFullScreen(fullScreen: Boolean)
expect suspend fun loadReaderPagingOnly(): Boolean
expect suspend fun saveReaderPagingOnly(enabled: Boolean)
expect suspend fun loadScanDownloadsAutomatically(): Boolean expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean) expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
@@ -160,6 +160,7 @@ internal open class AppStrings {
open val readerTypography = "Typography" open val readerTypography = "Typography"
open val readerLayout = "Layout" open val readerLayout = "Layout"
open val readerNavigation = "Navigation" open val readerNavigation = "Navigation"
open val readerPagingOnly = "Paging only (no free scroll)"
open val readerProgress = "Progress" open val readerProgress = "Progress"
open val closeReaderSettings = "Close reader settings" open val closeReaderSettings = "Close reader settings"
open val share = "Share" open val share = "Share"
@@ -388,6 +389,7 @@ internal object RussianStrings : AppStrings() {
override val readerTypography = "Типографика" override val readerTypography = "Типографика"
override val readerLayout = "Макет" override val readerLayout = "Макет"
override val readerNavigation = "Навигация" override val readerNavigation = "Навигация"
override val readerPagingOnly = "Постраничный режим (без прокрутки)"
override val readerProgress = "Прогресс" override val readerProgress = "Прогресс"
override val closeReaderSettings = "Закрыть настройки чтения" override val closeReaderSettings = "Закрыть настройки чтения"
override val share = "Поделиться" override val share = "Поделиться"
@@ -0,0 +1,67 @@
package net.sergeych.toread
internal data class ReadAloudBlockRange(val position: Int, val start: Int, val endExclusive: Int)
internal data class ReadAloudBlock(
val text: String,
val ranges: List<ReadAloudBlockRange>,
val pauseBeforeMillis: Long,
val pauseAfterMillis: Long,
) {
fun positionAt(offset: Int): Int =
ranges.lastOrNull { it.start <= offset }?.position ?: ranges.first().position
}
/** Paragraph-sized requests. Android's hard limit counts UTF-16 characters, never UTF-8 bytes.
* Whole sentences are retained whenever they fit. An oversized sentence is split at whitespace,
* or, for an oversized word, between Unicode code points (never inside a surrogate pair).
*/
internal fun readAloudBlocks(
sentences: List<ReadAloudSentence>,
startIndex: Int,
maxLength: Int,
): Sequence<ReadAloudBlock> = sequence {
require(maxLength >= 2)
var text = StringBuilder()
var ranges = mutableListOf<ReadAloudBlockRange>()
var before = 0L
var after = 0L
var item: Int? = null
suspend fun SequenceScope<ReadAloudBlock>.flush() {
if (ranges.isEmpty()) return
yield(ReadAloudBlock(text.toString(), ranges.toList(), before, after))
text = StringBuilder()
ranges = mutableListOf()
before = 0L
after = 0L
}
for (position in startIndex.coerceAtLeast(0) until sentences.size) {
val sentence = sentences[position]
if (item != sentence.itemIndex || sentence.pauseBeforeMillis > 0 || after > 0) flush()
item = sentence.itemIndex
var offset = 0
val spoken = sentence.spokenText
while (offset < spoken.length) {
// Do not split a sentence just to fill the previous block.
if (text.isNotEmpty() && text.length + 1 + spoken.length - offset > maxLength) flush()
if (ranges.isEmpty()) before = if (offset == 0) sentence.pauseBeforeMillis else 0L
var end = minOf(spoken.length, offset + maxLength)
if (end < spoken.length) {
if (spoken[end - 1].isHighSurrogate() && spoken[end].isLowSurrogate()) end--
val whitespace = (end - 1 downTo offset + 1).firstOrNull { spoken[it].isWhitespace() }
if (whitespace != null) end = whitespace
}
if (text.isNotEmpty()) text.append(' ')
val start = text.length
text.append(spoken.substring(offset, end))
ranges += ReadAloudBlockRange(position, start, text.length)
offset = end
while (offset < spoken.length && spoken[offset].isWhitespace()) offset++
after = if (offset == spoken.length) sentence.pauseAfterMillis else 0L
if (offset < spoken.length || text.length >= maxLength || after > 0) flush()
}
}
flush()
}
@@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -127,6 +128,7 @@ internal fun ContinuousBookReader(
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(), readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
readerBackgroundColor: Color = MaterialTheme.colorScheme.surface, readerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
readerTextColor: Color = MaterialTheme.colorScheme.onSurface, readerTextColor: Color = MaterialTheme.colorScheme.onSurface,
pagingOnly: Boolean = false,
onReaderFontZoom: (Int) -> Unit = {}, onReaderFontZoom: (Int) -> Unit = {},
onUserScroll: () -> Unit = {}, onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
@@ -153,6 +155,7 @@ internal fun ContinuousBookReader(
modifier = modifier modifier = modifier
.background(readerBackgroundColor) .background(readerBackgroundColor)
.nestedScroll(userScrollConnection) .nestedScroll(userScrollConnection)
.blockFreeScroll(pagingOnly)
.readerFontZoomGesture(onReaderFontZoom) .readerFontZoomGesture(onReaderFontZoom)
.pageTurnOnTouchSwipe( .pageTurnOnTouchSwipe(
onPageDown = { onPageDown = {
@@ -213,7 +216,6 @@ internal fun ContinuousBookReader(
) )
.padding( .padding(
top = if (element.depth == 0) 22.dp else 14.dp, top = if (element.depth == 0) 22.dp else 14.dp,
start = (element.depth * 12).dp,
bottom = 4.dp, bottom = 4.dp,
) )
Text( Text(
@@ -242,7 +244,6 @@ internal fun ContinuousBookReader(
style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor), style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Justify, textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
onLinkOpen = onNoteOpen, onLinkOpen = onNoteOpen,
) )
@@ -333,6 +334,43 @@ private fun List<androidx.compose.ui.input.pointer.PointerInputChange>.firstTwoD
return sqrt(dx * dx + dy * dy) return sqrt(dx * dx + dy * dy)
} }
/**
* Consumes vertical touch drags and mouse wheel/trackpad scroll so the lazy list
* cannot be scrolled freely ("paging only" mode). Taps and horizontal swipes are
* left untouched so page-turn gestures and clickable items keep working.
*/
private fun Modifier.blockFreeScroll(enabled: Boolean): Modifier =
if (!enabled) {
this
} else {
pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
if (down.isConsumed) return@awaitEachGesture
val touchSlop = viewConfiguration.touchSlop
val touchSlopSquared = touchSlop * touchSlop
var verticalDragLocked = false
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
if (event.type == PointerEventType.Scroll) {
event.changes.forEach { it.consume() }
continue
}
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
if (!verticalDragLocked) {
val dx = change.position.x - down.position.x
val dy = change.position.y - down.position.y
if (dy * dy > touchSlopSquared && dy * dy > ReaderHorizontalSwipeDominance * dx * dx) {
verticalDragLocked = true
}
}
if (verticalDragLocked) change.consume()
}
}
}
}
private fun Modifier.pageTurnOnTouchSwipe( private fun Modifier.pageTurnOnTouchSwipe(
onPageDown: () -> Unit, onPageDown: () -> Unit,
onPageUp: () -> Unit, onPageUp: () -> Unit,
@@ -953,7 +991,7 @@ private fun ReaderPoem(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = (depth * 8).dp, top = 8.dp, bottom = 8.dp), .padding(top = 8.dp, bottom = 8.dp),
) { ) {
poem.epigraphs.forEachIndexed { index, epigraph -> poem.epigraphs.forEachIndexed { index, epigraph ->
ReaderEpigraph( ReaderEpigraph(
@@ -1008,7 +1046,7 @@ private fun ReaderEpigraph(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = (depth * 8 + 26).dp, end = 18.dp, top = 8.dp, bottom = 8.dp), .padding(start = 26.dp, end = 18.dp, top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
epigraph.blocks.forEach { block -> epigraph.blocks.forEach { block ->
@@ -1051,7 +1089,7 @@ private fun ReaderCite(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = (depth * 8 + 22).dp, end = 14.dp, top = 8.dp, bottom = 8.dp), .padding(start = 22.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
cite.blocks.forEach { block -> cite.blocks.forEach { block ->
@@ -70,6 +70,7 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -151,6 +152,7 @@ internal fun BookView(
var readerExtraSettingsVisible by remember(fileId) { mutableStateOf(false) } var readerExtraSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerColorPickerTarget by remember(fileId) { mutableStateOf<ReaderColorPickerTarget?>(null) } var readerColorPickerTarget by remember(fileId) { mutableStateOf<ReaderColorPickerTarget?>(null) }
var fullScreenReader by remember { mutableStateOf(false) } var fullScreenReader by remember { mutableStateOf(false) }
var readerPagingOnly by remember { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) } var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) } var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) } var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
@@ -233,6 +235,14 @@ internal fun BookView(
} }
} }
fun updateReaderPagingOnly(pagingOnly: Boolean) {
if (pagingOnly == readerPagingOnly) return
readerPagingOnly = pagingOnly
scope.launch {
saveReaderPagingOnly(pagingOnly)
}
}
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition = fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
ReadingPosition( ReadingPosition(
listState.firstVisibleItemIndex, listState.firstVisibleItemIndex,
@@ -302,6 +312,7 @@ internal fun BookView(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
readerFontSettings = loadReaderFontSettings() readerFontSettings = loadReaderFontSettings()
fullScreenReader = loadReaderFullScreen() fullScreenReader = loadReaderFullScreen()
readerPagingOnly = loadReaderPagingOnly()
} }
DisposableEffect(fileId) { DisposableEffect(fileId) {
@@ -436,11 +447,17 @@ internal fun BookView(
}, },
showReadAloudAction = showReadAloudAction, showReadAloudAction = showReadAloudAction,
onReadAloud = { onReadAloud = {
val position = currentReadingPosition() if (readAloudPanelVisible) {
val startIndex = contentPlan.resumeSentenceIndex(position) ReadAloudPlatform.stop()
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) readAloudPanelVisible = false
readAloudPanelVisible = true } else {
ReadAloudPlatform.play() if (!readAloudState.active) {
val startIndex = contentPlan.resumeSentenceIndex(currentReadingPosition())
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
}
readAloudPanelVisible = true
ReadAloudPlatform.play()
}
}, },
onDelete = { onDelete = {
onDeleteRequested( onDeleteRequested(
@@ -489,6 +506,7 @@ internal fun BookView(
readerFontSettings = readerFontSettings, readerFontSettings = readerFontSettings,
readerBackgroundColor = readerBackgroundColor, readerBackgroundColor = readerBackgroundColor,
readerTextColor = readerTextColor, readerTextColor = readerTextColor,
pagingOnly = readerPagingOnly,
onReaderFontZoom = { steps -> onReaderFontZoom = { steps ->
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) } updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
}, },
@@ -550,15 +568,18 @@ internal fun BookView(
onClose = { readerSettingsPanelVisible = false }, onClose = { readerSettingsPanelVisible = false },
) )
} }
if (readAloudPanelVisible && readAloudState.active) { if (readAloudPanelVisible) {
ReadAloudPanel( ReadAloudPanel(
playing = readAloudState.playing, playing = readAloudState.playing,
buffering = readAloudState.buffering, buffering = readAloudState.buffering,
onPlayStop = { onPlayStop = {
if (readAloudState.playing) { if (readAloudState.playing) {
ReadAloudPlatform.stop() ReadAloudPlatform.stop()
readAloudPanelVisible = false
} else { } else {
if (!readAloudState.active) {
val startIndex = contentPlan.resumeSentenceIndex(currentReadingPosition())
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
}
ReadAloudPlatform.play() ReadAloudPlatform.play()
} }
}, },
@@ -596,6 +617,8 @@ internal fun BookView(
onRevertColorScheme = { onRevertColorScheme = {
updateReaderFontSettings { settings -> settings.resetThemeColorScheme(useDarkReaderTheme) } updateReaderFontSettings { settings -> settings.resetThemeColorScheme(useDarkReaderTheme) }
}, },
pagingOnly = readerPagingOnly,
onPagingOnlyChange = { pagingOnly -> updateReaderPagingOnly(pagingOnly) },
onDismiss = { readerExtraSettingsVisible = false }, onDismiss = { readerExtraSettingsVisible = false },
) )
} }
@@ -1384,6 +1407,8 @@ private fun ColorSwatchIcon(color: Color) {
@Composable @Composable
private fun ReaderExtraSettingsDialog( private fun ReaderExtraSettingsDialog(
onRevertColorScheme: () -> Unit, onRevertColorScheme: () -> Unit,
pagingOnly: Boolean,
onPagingOnlyChange: (Boolean) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
AlertDialog( AlertDialog(
@@ -1417,6 +1442,30 @@ private fun ReaderExtraSettingsDialog(
} }
HorizontalDivider() HorizontalDivider()
} }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPagingOnlyChange(!pagingOnly) }
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = strings.readerNavigation,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = strings.readerPagingOnly,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
Switch(
checked = pagingOnly,
onCheckedChange = { onPagingOnlyChange(it) },
)
}
HorizontalDivider()
} }
}, },
confirmButton = { confirmButton = {
@@ -1542,7 +1591,6 @@ private fun readerExtraSettingOptions(): List<String> =
strings.readerColors, strings.readerColors,
strings.readerTypography, strings.readerTypography,
strings.readerLayout, strings.readerLayout,
strings.readerNavigation,
strings.readerProgress, strings.readerProgress,
) )
@@ -0,0 +1,58 @@
package net.sergeych.toread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReadAloudBlocksTest {
private fun sentence(index: Int, item: Int, text: String, before: Long = 0, after: Long = 0) =
ReadAloudSentence(index, item, 0, text.length, text, pauseBeforeMillis = before, pauseAfterMillis = after)
@Test
fun paragraphsKeepContextAndRangesUseSpokenText() {
val queue = listOf(
sentence(0, 1, "Первое.").copy(spokenText = "Пе́рвое."),
sentence(1, 1, "Второе."), sentence(2, 2, "Другой абзац."),
)
val blocks = readAloudBlocks(queue, 0, 4_000).toList()
assertEquals(listOf("Пе́рвое. Второе.", "Другой абзац."), blocks.map { it.text })
assertEquals(0, blocks[0].positionAt(0))
assertEquals(1, blocks[0].positionAt(9))
assertEquals(listOf("Второе.", "Другой абзац."), readAloudBlocks(queue, 1, 4_000).map { it.text }.toList())
}
@Test
fun longParagraphBreaksBetweenSentencesAndCountsCharacters() {
val queue = (0..9).map { sentence(it, 1, "я".repeat(998) + ".") }
val blocks = readAloudBlocks(queue, 0, 4_000).toList()
assertEquals(listOf(3_999, 3_999, 1_999), blocks.map { it.text.length })
assertEquals((0..9).toList(), blocks.flatMap { it.ranges }.map { it.position })
}
@Test
fun oversizedSentenceSplitsAtWordsAndNeverCutsSurrogatePairs() {
val words = List(20) { "Привет😀" }
val blocks = readAloudBlocks(listOf(sentence(0, 0, words.joinToString(" "))), 0, 30).toList()
assertEquals(words.joinToString(" "), blocks.joinToString(" ") { it.text })
assertTrue(blocks.all { it.text.length <= 30 })
val cyrillic = "я".repeat(9_001) + "."
val cyrillicBlocks = readAloudBlocks(listOf(sentence(0, 0, cyrillic)), 0, 4_000).toList()
assertEquals(cyrillic, cyrillicBlocks.joinToString("") { it.text })
assertEquals(listOf(4_000, 4_000, 1_002), cyrillicBlocks.map { it.text.length })
val giantWord = "😀".repeat(4_001)
val split = readAloudBlocks(listOf(sentence(0, 0, giantWord)), 0, 3_999).toList()
assertEquals(giantWord, split.joinToString("") { it.text })
assertTrue(split.all { it.text.length <= 3_999 && !it.text.last().isHighSurrogate() })
}
@Test
fun pausesAndStartingPositionArePreserved() {
val queue = listOf(sentence(0, 0, "Skip."), sentence(1, 0, "Heading.", 1000, 600),
sentence(2, 1, "Wait.", after = 350), sentence(3, 1, "Go."))
val blocks = readAloudBlocks(queue, 1, 4_000).toList()
assertEquals(listOf("Heading.", "Wait.", "Go."), blocks.map { it.text })
assertEquals(listOf(1000L, 0L, 0L), blocks.map { it.pauseBeforeMillis })
assertEquals(listOf(600L, 350L, 0L), blocks.map { it.pauseAfterMillis })
assertEquals(listOf(1, 2, 3), blocks.map { it.ranges.first().position })
}
}
@@ -461,6 +461,18 @@ actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispa
} }
} }
actual suspend fun loadReaderPagingOnly(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderPagingOnlyFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderPagingOnly(enabled: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderPagingOnlyFlag, enabled.toString())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) { actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@@ -751,6 +763,7 @@ private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings" private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen" private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ReaderPagingOnlyFlag = "reader_paging_only"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter" private const val LibraryFilterFlag = "library_filter"
private const val AppLocaleTagFlag = "app_locale_tag" private const val AppLocaleTagFlag = "app_locale_tag"
@@ -90,6 +90,13 @@ actual suspend fun saveReaderFullScreen(fullScreen: Boolean) {
window.localStorage.setItem(ReaderFullScreenStorageKey, fullScreen.toString()) window.localStorage.setItem(ReaderFullScreenStorageKey, fullScreen.toString())
} }
actual suspend fun loadReaderPagingOnly(): Boolean =
window.localStorage.getItem(ReaderPagingOnlyStorageKey)?.toBooleanStrictOrNull() ?: false
actual suspend fun saveReaderPagingOnly(enabled: Boolean) {
window.localStorage.setItem(ReaderPagingOnlyStorageKey, enabled.toString())
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = true actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
@@ -152,6 +159,7 @@ private const val AppLocaleStorageKey = "toread.appLocaleTag"
private const val LibraryFilterStorageKey = "toread.libraryFilter" private const val LibraryFilterStorageKey = "toread.libraryFilter"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings" private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
private const val ReaderFullScreenStorageKey = "toread.readerFullScreen" private const val ReaderFullScreenStorageKey = "toread.readerFullScreen"
private const val ReaderPagingOnlyStorageKey = "toread.readerPagingOnly"
actual fun formatLibraryLastReadTime(millis: Long): String { actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L val totalMinutes = millis / 60_000L