diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3ea49bf..d0f444a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 { diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 6f7d8de..5fcd9c3 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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" diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt index 88017a7..e9bd678 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt @@ -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 = emptyList() private var currentIndex: Int = 0 + private var playbackGeneration = 0L + private var blockNumber = 0L + private var activeUtteranceId: String? = null + private var blocks: Iterator? = null + private val queuedBlocks = linkedMapOf() + 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, 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 -> - if (status != TextToSpeech.SUCCESS) { - 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 = TextToSpeech(appContext) { status -> + // Dispatch even if onInit fires synchronously inside the constructor. + scope.launch(Dispatchers.Main) { handleEngineProbeInit(appContext, generation, status) } } - 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?) { 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?) { - if (!mutableState.value.playing) return - val speakIndex = utteranceId?.removePrefix(SpeakPrefix)?.takeIf { it != utteranceId }?.toIntOrNull() - val afterIndex = utteranceId?.removePrefix(AfterPausePrefix)?.takeIf { it != utteranceId }?.toIntOrNull() - val finishedIndex = afterIndex ?: speakIndex ?: return - if (finishedIndex != currentIndex) return - val currentSentence = sentences.getOrNull(currentIndex) ?: run { - stop() - return - } - if (speakIndex != null && currentSentence.pauseAfterMillis > 0) { - sentences.getOrNull(currentIndex + 1)?.let { nextSentence -> - mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index) - saveSentencePosition(nextSentence) + 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 } - return } - if (currentIndex < sentences.lastIndex) { - currentIndex += 1 - speakCurrent() - } else { - stop() + 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 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 } @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 8194378..9162de4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index 457f21c..3f51cb1 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -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 = "Поделиться" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudBlocks.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudBlocks.kt new file mode 100644 index 0000000..6331766 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudBlocks.kt @@ -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, + 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, + startIndex: Int, + maxLength: Int, +): Sequence = sequence { + require(maxLength >= 2) + var text = StringBuilder() + var ranges = mutableListOf() + var before = 0L + var after = 0L + var item: Int? = null + + suspend fun SequenceScope.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() +} diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 33151fc..486a1a4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -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.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 -> diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index ef8db06..c1409c6 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -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(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>(emptyList()) } var tableOfContentsBackPosition by remember(fileId) { mutableStateOf(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 = 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) - ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) - readAloudPanelVisible = true - ReadAloudPlatform.play() + 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 = strings.readerColors, strings.readerTypography, strings.readerLayout, - strings.readerNavigation, strings.readerProgress, ) diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudBlocksTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudBlocksTest.kt new file mode 100644 index 0000000..f4a2238 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudBlocksTest.kt @@ -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 }) + } +} diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index 617618b..da706e8 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -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" diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 9a2cc03..68fadd8 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -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