TTS improvements
This commit is contained in:
1 parent
9dbe30b20c
commit
a2f417d147
11 files changed
+414
-92
No files matched your search
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val appVersionName = "1.1"
|
||||
val appVersionCode = 9
|
||||
val appVersionCode = 10
|
||||
val appVersionDisplay = "$appVersionName.$appVersionCode"
|
||||
|
||||
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) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
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 ReaderFontSettingsFlag = "reader_font_settings"
|
||||
private const val ReaderFullScreenFlag = "reader_full_screen"
|
||||
private const val ReaderPagingOnlyFlag = "reader_paging_only"
|
||||
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
|
||||
private const val LibraryFilterFlag = "library_filter"
|
||||
private const val AppLocaleTagFlag = "app_locale_tag"
|
||||
|
||||
+152
-81
@@ -15,6 +15,7 @@ import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -72,7 +73,6 @@ actual object ReadAloudPlatform {
|
||||
}
|
||||
|
||||
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-"
|
||||
@@ -97,13 +97,27 @@ private object AndroidReadAloudEngine {
|
||||
private var ttsEngineId: String? = null
|
||||
private var engineProbe: 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 fileId: String? = null
|
||||
private var bookTitle: String = ""
|
||||
private var sentences: List<ReadAloudSentence> = emptyList()
|
||||
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) {
|
||||
shouldSpeakWhenReady = false
|
||||
cancelQueue()
|
||||
this.fileId = fileId
|
||||
bookTitle = title
|
||||
sentences = queue
|
||||
@@ -116,6 +130,7 @@ private object AndroidReadAloudEngine {
|
||||
ensureTts(context.applicationContext)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun play(context: Context) {
|
||||
if (sentences.isEmpty()) return
|
||||
ensureTts(context.applicationContext)
|
||||
@@ -124,6 +139,7 @@ private object AndroidReadAloudEngine {
|
||||
mutableState.value = mutableState.value.copy(
|
||||
active = true,
|
||||
playing = true,
|
||||
buffering = true,
|
||||
sentenceIndex = sentences.getOrNull(currentIndex)?.index,
|
||||
)
|
||||
return
|
||||
@@ -131,12 +147,14 @@ private object AndroidReadAloudEngine {
|
||||
speakCurrent()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
shouldSpeakWhenReady = false
|
||||
tts?.stop()
|
||||
cancelQueue()
|
||||
mutableState.value = ReadAloudState()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun skip(delta: Int) {
|
||||
if (sentences.isEmpty()) return
|
||||
val wasPlaying = mutableState.value.playing
|
||||
@@ -153,51 +171,53 @@ private object AndroidReadAloudEngine {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshSettings(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val textReplacements = userTextReplacements(appContext)
|
||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||
textReplacements = textReplacements,
|
||||
textReplacements = userTextReplacements(appContext),
|
||||
loading = true,
|
||||
message = null,
|
||||
)
|
||||
val generation = ++engineProbeGeneration
|
||||
engineProbe?.shutdown()
|
||||
var probe: TextToSpeech? = null
|
||||
probe = TextToSpeech(appContext) { status ->
|
||||
engineProbe = TextToSpeech(appContext) { status ->
|
||||
// Dispatch even if onInit fires synchronously inside the constructor.
|
||||
scope.launch(Dispatchers.Main) { handleEngineProbeInit(appContext, generation, status) }
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
loading = false, message = strings.couldNotLoadAndroidTtsEngines,
|
||||
)
|
||||
probe?.shutdown()
|
||||
if (engineProbe === probe) engineProbe = null
|
||||
return@TextToSpeech
|
||||
probe.shutdown()
|
||||
engineProbe = null
|
||||
return
|
||||
}
|
||||
|
||||
val engines = probe?.engines.orEmpty()
|
||||
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)) {
|
||||
var selectedEngine = selectedEngineId(context)
|
||||
if (selectedEngine == null && !context.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
|
||||
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
|
||||
if (selectedEngine != null) {
|
||||
appContext.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
|
||||
context.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
|
||||
}
|
||||
}
|
||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||
engines = engines,
|
||||
selectedEngineId = selectedEngine,
|
||||
textReplacements = textReplacements,
|
||||
engines = engines, selectedEngineId = selectedEngine,
|
||||
)
|
||||
probe?.shutdown()
|
||||
if (engineProbe === probe) engineProbe = null
|
||||
refreshVoices(appContext, selectedEngine)
|
||||
}
|
||||
engineProbe = probe
|
||||
probe.shutdown()
|
||||
engineProbe = null
|
||||
refreshVoices(context, selectedEngine)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun selectEngine(context: Context, engineId: String?) {
|
||||
val appContext = context.applicationContext
|
||||
appContext.readAloudPrefs()
|
||||
@@ -206,6 +226,7 @@ private object AndroidReadAloudEngine {
|
||||
.putStringOrRemove(SelectedEngineKey, engineId)
|
||||
.remove(SelectedVoiceKey)
|
||||
.apply()
|
||||
val wasPlaying = mutableState.value.playing
|
||||
resetTts()
|
||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||
selectedEngineId = engineId,
|
||||
@@ -215,8 +236,10 @@ private object AndroidReadAloudEngine {
|
||||
message = null,
|
||||
)
|
||||
refreshVoices(appContext, engineId)
|
||||
if (wasPlaying) play(appContext)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun selectVoice(context: Context, voiceId: String?) {
|
||||
val appContext = context.applicationContext
|
||||
appContext.readAloudPrefs()
|
||||
@@ -236,25 +259,35 @@ private object AndroidReadAloudEngine {
|
||||
if (tts != null && ttsEngineId == selectedEngine) return
|
||||
resetTts()
|
||||
ttsEngineId = selectedEngine
|
||||
val generation = ttsGeneration
|
||||
tts = if (selectedEngine == null) {
|
||||
TextToSpeech(appContext) { status ->
|
||||
handleTtsInit(appContext, status)
|
||||
scope.launch(Dispatchers.Main) { handleTtsInit(appContext, generation, status) }
|
||||
}
|
||||
} else {
|
||||
TextToSpeech(
|
||||
appContext,
|
||||
{ status -> handleTtsInit(appContext, status) },
|
||||
{ status -> scope.launch(Dispatchers.Main) { handleTtsInit(appContext, generation, status) } },
|
||||
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
|
||||
if (ttsReady) {
|
||||
applyTtsConfiguration(context)
|
||||
Log.i("YereedTts", "Initialized requested engine=${ttsEngineId ?: "system default"}, voice=${tts?.voice?.name}")
|
||||
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?) {
|
||||
handleUtteranceDone(utteranceId)
|
||||
@@ -262,11 +295,11 @@ private object AndroidReadAloudEngine {
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) {
|
||||
stop()
|
||||
handleUtteranceError(utteranceId)
|
||||
}
|
||||
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
stop()
|
||||
handleUtteranceError(utteranceId)
|
||||
}
|
||||
})
|
||||
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() {
|
||||
val sentence = sentences.getOrNull(currentIndex) ?: run {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
cancelQueue()
|
||||
mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index)
|
||||
saveSentencePosition(sentence)
|
||||
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.spokenText, queueMode, params, "$SpeakPrefix$currentIndex")
|
||||
if (sentence.pauseAfterMillis > 0) {
|
||||
tts?.playSilentUtterance(sentence.pauseAfterMillis, TextToSpeech.QUEUE_ADD, "$AfterPausePrefix$currentIndex")
|
||||
}
|
||||
blocks = readAloudBlocks(
|
||||
sentences, currentIndex, minOf(4_000, TextToSpeech.getMaxSpeechInputLength()),
|
||||
).iterator()
|
||||
fillQueue()
|
||||
}
|
||||
|
||||
private fun handleUtteranceDone(utteranceId: String?) {
|
||||
private fun fillQueue() {
|
||||
val engine = tts ?: return
|
||||
val remaining = blocks ?: return
|
||||
// Keep the current block and two successors submitted, before playback ends.
|
||||
while (queuedBlocks.size < 3 && remaining.hasNext()) {
|
||||
val block = remaining.next()
|
||||
val id = "$SpeakPrefix$playbackGeneration-${blockNumber++}"
|
||||
queuedBlocks[id] = block
|
||||
val beforeResult = if (block.pauseBeforeMillis > 0) {
|
||||
engine.playSilentUtterance(block.pauseBeforeMillis, TextToSpeech.QUEUE_ADD, beforeId(id))
|
||||
} else TextToSpeech.SUCCESS
|
||||
val speechResult = engine.speak(block.text, TextToSpeech.QUEUE_ADD, Bundle(), id)
|
||||
val afterResult = if (block.pauseAfterMillis > 0) {
|
||||
engine.playSilentUtterance(block.pauseAfterMillis, TextToSpeech.QUEUE_ADD, afterId(id))
|
||||
} else TextToSpeech.SUCCESS
|
||||
if (beforeResult == TextToSpeech.ERROR || speechResult == TextToSpeech.ERROR ||
|
||||
afterResult == TextToSpeech.ERROR) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (queuedBlocks.isEmpty()) stop()
|
||||
}
|
||||
|
||||
private fun updatePosition(position: Int) {
|
||||
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 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
|
||||
val block = queuedBlocks[utteranceId] ?: return
|
||||
activeUtteranceId = utteranceId
|
||||
updatePosition(block.ranges.first().position)
|
||||
}
|
||||
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) {
|
||||
sentences.getOrNull(currentIndex + 1)?.let { nextSentence ->
|
||||
mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index)
|
||||
saveSentencePosition(nextSentence)
|
||||
|
||||
@Synchronized
|
||||
private fun handleUtteranceRange(utteranceId: String?, offset: Int) {
|
||||
if (!mutableState.value.playing || utteranceId != activeUtteranceId) return
|
||||
val block = queuedBlocks[utteranceId] ?: return
|
||||
updatePosition(block.positionAt(offset))
|
||||
}
|
||||
return
|
||||
|
||||
@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
|
||||
}
|
||||
if (currentIndex < sentences.lastIndex) {
|
||||
currentIndex += 1
|
||||
speakCurrent()
|
||||
} else {
|
||||
stop()
|
||||
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 }
|
||||
@@ -341,34 +417,28 @@ private object AndroidReadAloudEngine {
|
||||
}
|
||||
|
||||
private fun refreshVoices(context: Context, engineId: String?) {
|
||||
val generation = ++voiceProbeGeneration
|
||||
voiceProbe?.shutdown()
|
||||
var probe: TextToSpeech? = null
|
||||
probe = if (engineId == null) {
|
||||
TextToSpeech(context) { status ->
|
||||
handleVoiceProbeInit(context, probe, status)
|
||||
}
|
||||
} else {
|
||||
TextToSpeech(
|
||||
context,
|
||||
{ status -> handleVoiceProbeInit(context, probe, status) },
|
||||
engineId,
|
||||
)
|
||||
}
|
||||
voiceProbe = probe
|
||||
voiceProbe = TextToSpeech(context, { status ->
|
||||
scope.launch(Dispatchers.Main) { handleVoiceProbeInit(context, generation, status) }
|
||||
}, engineId)
|
||||
}
|
||||
|
||||
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) {
|
||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||
loading = false,
|
||||
message = strings.couldNotLoadSelectedTtsVoices,
|
||||
)
|
||||
probe?.shutdown()
|
||||
probe.shutdown()
|
||||
if (voiceProbe === probe) voiceProbe = null
|
||||
return
|
||||
}
|
||||
|
||||
val voices = probe?.voices.orEmpty()
|
||||
val voices = probe.voices.orEmpty()
|
||||
.mapNotNull { voice ->
|
||||
val locale = voice.locale ?: return@mapNotNull null
|
||||
ReadAloudVoiceOption(
|
||||
@@ -396,7 +466,7 @@ private object AndroidReadAloudEngine {
|
||||
if (savedVoice == null && selectedVoiceId(context) != null) {
|
||||
context.readAloudPrefs().edit().remove(SelectedVoiceKey).apply()
|
||||
}
|
||||
probe?.shutdown()
|
||||
probe.shutdown()
|
||||
if (voiceProbe === probe) voiceProbe = null
|
||||
}
|
||||
|
||||
@@ -413,10 +483,11 @@ private object AndroidReadAloudEngine {
|
||||
}
|
||||
|
||||
private fun resetTts() {
|
||||
ttsGeneration++
|
||||
shouldSpeakWhenReady = false
|
||||
ttsReady = false
|
||||
ttsEngineId = null
|
||||
tts?.stop()
|
||||
cancelQueue()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ expect suspend fun loadReaderFullScreen(): 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 saveScanDownloadsAutomatically(enabled: Boolean)
|
||||
|
||||
@@ -160,6 +160,7 @@ internal open class AppStrings {
|
||||
open val readerTypography = "Typography"
|
||||
open val readerLayout = "Layout"
|
||||
open val readerNavigation = "Navigation"
|
||||
open val readerPagingOnly = "Paging only (no free scroll)"
|
||||
open val readerProgress = "Progress"
|
||||
open val closeReaderSettings = "Close reader settings"
|
||||
open val share = "Share"
|
||||
@@ -388,6 +389,7 @@ internal object RussianStrings : AppStrings() {
|
||||
override val readerTypography = "Типографика"
|
||||
override val readerLayout = "Макет"
|
||||
override val readerNavigation = "Навигация"
|
||||
override val readerPagingOnly = "Постраничный режим (без прокрутки)"
|
||||
override val readerProgress = "Прогресс"
|
||||
override val closeReaderSettings = "Закрыть настройки чтения"
|
||||
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.graphicsLayer
|
||||
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.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -127,6 +128,7 @@ internal fun ContinuousBookReader(
|
||||
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
|
||||
readerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
readerTextColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
pagingOnly: Boolean = false,
|
||||
onReaderFontZoom: (Int) -> Unit = {},
|
||||
onUserScroll: () -> Unit = {},
|
||||
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||
@@ -153,6 +155,7 @@ internal fun ContinuousBookReader(
|
||||
modifier = modifier
|
||||
.background(readerBackgroundColor)
|
||||
.nestedScroll(userScrollConnection)
|
||||
.blockFreeScroll(pagingOnly)
|
||||
.readerFontZoomGesture(onReaderFontZoom)
|
||||
.pageTurnOnTouchSwipe(
|
||||
onPageDown = {
|
||||
@@ -213,7 +216,6 @@ internal fun ContinuousBookReader(
|
||||
)
|
||||
.padding(
|
||||
top = if (element.depth == 0) 22.dp else 14.dp,
|
||||
start = (element.depth * 12).dp,
|
||||
bottom = 4.dp,
|
||||
)
|
||||
Text(
|
||||
@@ -242,7 +244,6 @@ internal fun ContinuousBookReader(
|
||||
style = readerParagraphTextStyle(book.language, readerFontSettings).copy(color = readerTextColor),
|
||||
highlightedRange = highlightedRange,
|
||||
textAlign = TextAlign.Justify,
|
||||
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
|
||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
||||
onLinkOpen = onNoteOpen,
|
||||
)
|
||||
@@ -333,6 +334,43 @@ private fun List<androidx.compose.ui.input.pointer.PointerInputChange>.firstTwoD
|
||||
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(
|
||||
onPageDown: () -> Unit,
|
||||
onPageUp: () -> Unit,
|
||||
@@ -953,7 +991,7 @@ private fun ReaderPoem(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (depth * 8).dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
poem.epigraphs.forEachIndexed { index, epigraph ->
|
||||
ReaderEpigraph(
|
||||
@@ -1008,7 +1046,7 @@ private fun ReaderEpigraph(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.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),
|
||||
) {
|
||||
epigraph.blocks.forEach { block ->
|
||||
@@ -1051,7 +1089,7 @@ private fun ReaderCite(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.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),
|
||||
) {
|
||||
cite.blocks.forEach { block ->
|
||||
|
||||
@@ -70,6 +70,7 @@ import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -151,6 +152,7 @@ internal fun BookView(
|
||||
var readerExtraSettingsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var readerColorPickerTarget by remember(fileId) { mutableStateOf<ReaderColorPickerTarget?>(null) }
|
||||
var fullScreenReader by remember { mutableStateOf(false) }
|
||||
var readerPagingOnly by remember { mutableStateOf(false) }
|
||||
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
|
||||
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
|
||||
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 =
|
||||
ReadingPosition(
|
||||
listState.firstVisibleItemIndex,
|
||||
@@ -302,6 +312,7 @@ internal fun BookView(
|
||||
LaunchedEffect(Unit) {
|
||||
readerFontSettings = loadReaderFontSettings()
|
||||
fullScreenReader = loadReaderFullScreen()
|
||||
readerPagingOnly = loadReaderPagingOnly()
|
||||
}
|
||||
|
||||
DisposableEffect(fileId) {
|
||||
@@ -436,11 +447,17 @@ internal fun BookView(
|
||||
},
|
||||
showReadAloudAction = showReadAloudAction,
|
||||
onReadAloud = {
|
||||
val position = currentReadingPosition()
|
||||
val startIndex = contentPlan.resumeSentenceIndex(position)
|
||||
if (readAloudPanelVisible) {
|
||||
ReadAloudPlatform.stop()
|
||||
readAloudPanelVisible = false
|
||||
} else {
|
||||
if (!readAloudState.active) {
|
||||
val startIndex = contentPlan.resumeSentenceIndex(currentReadingPosition())
|
||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||
}
|
||||
readAloudPanelVisible = true
|
||||
ReadAloudPlatform.play()
|
||||
}
|
||||
},
|
||||
onDelete = {
|
||||
onDeleteRequested(
|
||||
@@ -489,6 +506,7 @@ internal fun BookView(
|
||||
readerFontSettings = readerFontSettings,
|
||||
readerBackgroundColor = readerBackgroundColor,
|
||||
readerTextColor = readerTextColor,
|
||||
pagingOnly = readerPagingOnly,
|
||||
onReaderFontZoom = { steps ->
|
||||
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
|
||||
},
|
||||
@@ -550,15 +568,18 @@ internal fun BookView(
|
||||
onClose = { readerSettingsPanelVisible = false },
|
||||
)
|
||||
}
|
||||
if (readAloudPanelVisible && readAloudState.active) {
|
||||
if (readAloudPanelVisible) {
|
||||
ReadAloudPanel(
|
||||
playing = readAloudState.playing,
|
||||
buffering = readAloudState.buffering,
|
||||
onPlayStop = {
|
||||
if (readAloudState.playing) {
|
||||
ReadAloudPlatform.stop()
|
||||
readAloudPanelVisible = false
|
||||
} else {
|
||||
if (!readAloudState.active) {
|
||||
val startIndex = contentPlan.resumeSentenceIndex(currentReadingPosition())
|
||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||
}
|
||||
ReadAloudPlatform.play()
|
||||
}
|
||||
},
|
||||
@@ -596,6 +617,8 @@ internal fun BookView(
|
||||
onRevertColorScheme = {
|
||||
updateReaderFontSettings { settings -> settings.resetThemeColorScheme(useDarkReaderTheme) }
|
||||
},
|
||||
pagingOnly = readerPagingOnly,
|
||||
onPagingOnlyChange = { pagingOnly -> updateReaderPagingOnly(pagingOnly) },
|
||||
onDismiss = { readerExtraSettingsVisible = false },
|
||||
)
|
||||
}
|
||||
@@ -1384,6 +1407,8 @@ private fun ColorSwatchIcon(color: Color) {
|
||||
@Composable
|
||||
private fun ReaderExtraSettingsDialog(
|
||||
onRevertColorScheme: () -> Unit,
|
||||
pagingOnly: Boolean,
|
||||
onPagingOnlyChange: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
@@ -1417,6 +1442,30 @@ private fun ReaderExtraSettingsDialog(
|
||||
}
|
||||
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 = {
|
||||
@@ -1542,7 +1591,6 @@ private fun readerExtraSettingOptions(): List<String> =
|
||||
strings.readerColors,
|
||||
strings.readerTypography,
|
||||
strings.readerLayout,
|
||||
strings.readerNavigation,
|
||||
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) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
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 ReaderFontSettingsFlag = "reader_font_settings"
|
||||
private const val ReaderFullScreenFlag = "reader_full_screen"
|
||||
private const val ReaderPagingOnlyFlag = "reader_paging_only"
|
||||
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
|
||||
private const val LibraryFilterFlag = "library_filter"
|
||||
private const val AppLocaleTagFlag = "app_locale_tag"
|
||||
|
||||
@@ -90,6 +90,13 @@ actual suspend fun saveReaderFullScreen(fullScreen: Boolean) {
|
||||
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 saveScanDownloadsAutomatically(enabled: Boolean) = Unit
|
||||
@@ -152,6 +159,7 @@ private const val AppLocaleStorageKey = "toread.appLocaleTag"
|
||||
private const val LibraryFilterStorageKey = "toread.libraryFilter"
|
||||
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
|
||||
private const val ReaderFullScreenStorageKey = "toread.readerFullScreen"
|
||||
private const val ReaderPagingOnlyStorageKey = "toread.readerPagingOnly"
|
||||
|
||||
actual fun formatLibraryLastReadTime(millis: Long): String {
|
||||
val totalMinutes = millis / 60_000L
|
||||
|
||||
Reference in new issue
Block a user