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
|
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"
|
||||||
|
|||||||
+152
-81
@@ -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.
|
||||||
|
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) {
|
if (status != TextToSpeech.SUCCESS) {
|
||||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||||
loading = false,
|
loading = false, message = strings.couldNotLoadAndroidTtsEngines,
|
||||||
message = strings.couldNotLoadAndroidTtsEngines,
|
|
||||||
)
|
)
|
||||||
probe?.shutdown()
|
probe.shutdown()
|
||||||
if (engineProbe === probe) engineProbe = null
|
engineProbe = null
|
||||||
return@TextToSpeech
|
return
|
||||||
}
|
}
|
||||||
|
val engines = probe.engines.orEmpty()
|
||||||
val engines = probe?.engines.orEmpty()
|
|
||||||
.map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
|
.map { ReadAloudEngineOption(id = it.name, label = it.label ?: it.name) }
|
||||||
.sortedBy { it.label.lowercase() }
|
.sortedBy { it.label.lowercase() }
|
||||||
var selectedEngine = selectedEngineId(appContext).takeIf { saved ->
|
var selectedEngine = selectedEngineId(context)
|
||||||
saved != null && engines.any { it.id == saved }
|
if (selectedEngine == null && !context.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
|
||||||
}
|
|
||||||
if (selectedEngine == null && !appContext.readAloudPrefs().getBoolean(EngineChoiceMadeKey, false)) {
|
|
||||||
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
|
selectedEngine = engines.firstOrNull { it.isRhVoice() }?.id
|
||||||
if (selectedEngine != null) {
|
if (selectedEngine != null) {
|
||||||
appContext.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
|
context.readAloudPrefs().edit().putString(SelectedEngineKey, selectedEngine).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||||
engines = engines,
|
engines = engines, selectedEngineId = selectedEngine,
|
||||||
selectedEngineId = selectedEngine,
|
|
||||||
textReplacements = textReplacements,
|
|
||||||
)
|
)
|
||||||
probe?.shutdown()
|
probe.shutdown()
|
||||||
if (engineProbe === probe) engineProbe = null
|
engineProbe = null
|
||||||
refreshVoices(appContext, selectedEngine)
|
refreshVoices(context, selectedEngine)
|
||||||
}
|
|
||||||
engineProbe = probe
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
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
|
if (!mutableState.value.playing) return
|
||||||
val speakIndex = utteranceId?.removePrefix(SpeakPrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
|
val block = queuedBlocks[utteranceId] ?: return
|
||||||
val afterIndex = utteranceId?.removePrefix(AfterPausePrefix)?.takeIf { it != utteranceId }?.toIntOrNull()
|
activeUtteranceId = utteranceId
|
||||||
val finishedIndex = afterIndex ?: speakIndex ?: return
|
updatePosition(block.ranges.first().position)
|
||||||
if (finishedIndex != currentIndex) return
|
|
||||||
val currentSentence = sentences.getOrNull(currentIndex) ?: run {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) {
|
|
||||||
sentences.getOrNull(currentIndex + 1)?.let { nextSentence ->
|
@Synchronized
|
||||||
mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index)
|
private fun handleUtteranceRange(utteranceId: String?, offset: Int) {
|
||||||
saveSentencePosition(nextSentence)
|
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) {
|
queuedBlocks.remove(id)
|
||||||
currentIndex += 1
|
fillQueue()
|
||||||
speakCurrent()
|
|
||||||
} else {
|
|
||||||
stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
readAloudPanelVisible = false
|
||||||
|
} else {
|
||||||
|
if (!readAloudState.active) {
|
||||||
|
val startIndex = contentPlan.resumeSentenceIndex(currentReadingPosition())
|
||||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||||
|
}
|
||||||
readAloudPanelVisible = true
|
readAloudPanelVisible = true
|
||||||
ReadAloudPlatform.play()
|
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
|
||||||
|
|||||||
Reference in new issue
Block a user