UI/TTS improvements

This commit is contained in:
Sergey Chernov 2026-05-23 21:55:34 +03:00
parent 0eba07b594
commit ed66ea0d55
13 changed files with 250 additions and 57 deletions

View File

@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.0" val appVersionName = "1.0"
val appVersionCode = 1 val appVersionCode = 2
val appVersionDisplay = "$appVersionName.$appVersionCode" val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins { plugins {

View File

@ -777,12 +777,17 @@ private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log") File(appContext.filesDir, "logs/toread.log")
private fun ReadingPosition.toFormatHintsJson(): String = private fun ReadingPosition.toFormatHintsJson(): String =
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}""" buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
append("}")
}
private fun String.toReadingPosition(): ReadingPosition? { private fun String.toReadingPosition(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset) else null val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
} }
private fun String.toSearchPrefixes(): List<String> = private fun String.toSearchPrefixes(): List<String> =

View File

@ -19,6 +19,7 @@ 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
import java.util.Locale import java.util.Locale
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -26,15 +27,17 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
actual object ReadAloudPlatform { actual object ReadAloudPlatform {
actual val isSupported: Boolean = true actual val isSupported: Boolean = true
actual val state: StateFlow<ReadAloudState> = AndroidReadAloudEngine.state actual val state: StateFlow<ReadAloudState> = AndroidReadAloudEngine.state
actual val settingsState: StateFlow<ReadAloudSettingsState> = AndroidReadAloudEngine.settingsState actual val settingsState: StateFlow<ReadAloudSettingsState> = AndroidReadAloudEngine.settingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) { actual fun prepare(fileId: String, bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) {
val context = androidAppContext() ?: return val context = androidAppContext() ?: return
AndroidReadAloudEngine.prepare(context, bookTitle, sentences, startIndex) AndroidReadAloudEngine.prepare(context, fileId, bookTitle, sentences, startIndex)
ReadAloudService.start(context) ReadAloudService.start(context)
} }
@ -83,6 +86,9 @@ private object AndroidReadAloudEngine {
val state: StateFlow<ReadAloudState> = mutableState val state: StateFlow<ReadAloudState> = mutableState
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val positionSaveMutex = Mutex()
private val positionSaveVersion = AtomicLong()
private var tts: TextToSpeech? = null private var tts: TextToSpeech? = null
private var ttsReady = false private var ttsReady = false
@ -90,11 +96,13 @@ private object AndroidReadAloudEngine {
private var engineProbe: TextToSpeech? = null private var engineProbe: TextToSpeech? = null
private var voiceProbe: TextToSpeech? = null private var voiceProbe: TextToSpeech? = null
private var shouldSpeakWhenReady = false private var shouldSpeakWhenReady = false
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
fun prepare(context: Context, title: String, queue: List<ReadAloudSentence>, startIndex: Int) { fun prepare(context: Context, fileId: String, title: String, queue: List<ReadAloudSentence>, startIndex: Int) {
this.fileId = fileId
bookTitle = title bookTitle = title
sentences = queue sentences = queue
currentIndex = startIndex.coerceIn(queue.indices.takeIf { queue.isNotEmpty() } ?: 0..0) currentIndex = startIndex.coerceIn(queue.indices.takeIf { queue.isNotEmpty() } ?: 0..0)
@ -111,7 +119,11 @@ private object AndroidReadAloudEngine {
ensureTts(context.applicationContext) ensureTts(context.applicationContext)
if (!ttsReady) { if (!ttsReady) {
shouldSpeakWhenReady = true shouldSpeakWhenReady = true
mutableState.value = mutableState.value.copy(active = true, playing = true, sentenceIndex = currentIndex) mutableState.value = mutableState.value.copy(
active = true,
playing = true,
sentenceIndex = sentences.getOrNull(currentIndex)?.index,
)
return return
} }
speakCurrent() speakCurrent()
@ -127,11 +139,13 @@ private object AndroidReadAloudEngine {
if (sentences.isEmpty()) return if (sentences.isEmpty()) return
val wasPlaying = mutableState.value.playing val wasPlaying = mutableState.value.playing
currentIndex = (currentIndex + delta).coerceIn(sentences.indices) currentIndex = (currentIndex + delta).coerceIn(sentences.indices)
val sentence = sentences.getOrNull(currentIndex)
mutableState.value = mutableState.value.copy( mutableState.value = mutableState.value.copy(
active = true, active = true,
playing = wasPlaying, playing = wasPlaying,
sentenceIndex = currentIndex, sentenceIndex = sentence?.index ?: currentIndex,
) )
sentence?.let(::saveSentencePosition)
if (wasPlaying && ttsReady) { if (wasPlaying && ttsReady) {
speakCurrent() speakCurrent()
} }
@ -262,6 +276,7 @@ private object AndroidReadAloudEngine {
return return
} }
mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index) mutableState.value = ReadAloudState(active = true, playing = true, sentenceIndex = sentence.index)
saveSentencePosition(sentence)
val params = Bundle().apply { val params = Bundle().apply {
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "$SpeakPrefix$currentIndex") putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "$SpeakPrefix$currentIndex")
} }
@ -288,6 +303,10 @@ private object AndroidReadAloudEngine {
return return
} }
if (speakIndex != null && currentSentence.pauseAfterMillis > 0) { if (speakIndex != null && currentSentence.pauseAfterMillis > 0) {
sentences.getOrNull(currentIndex + 1)?.let { nextSentence ->
mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index)
saveSentencePosition(nextSentence)
}
return return
} }
if (currentIndex < sentences.lastIndex) { if (currentIndex < sentences.lastIndex) {
@ -298,6 +317,21 @@ private object AndroidReadAloudEngine {
} }
} }
fun notificationTitle(): String = bookTitle.ifBlank { strings.readAloud }
private fun saveSentencePosition(sentence: ReadAloudSentence) {
val activeFileId = fileId ?: return
val saveVersion = positionSaveVersion.incrementAndGet()
scope.launch {
positionSaveMutex.withLock {
if (saveVersion != positionSaveVersion.get()) return@withLock
runCatching {
saveLibraryReadingPosition(activeFileId, ReadingPosition(sentence.itemIndex, 0, sentence.index))
}
}
}
}
private fun refreshVoices(context: Context, engineId: String?) { private fun refreshVoices(context: Context, engineId: String?) {
voiceProbe?.shutdown() voiceProbe?.shutdown()
var probe: TextToSpeech? = null var probe: TextToSpeech? = null
@ -448,7 +482,7 @@ class ReadAloudService : Service() {
return NotificationCompat.Builder(this, ChannelId) return NotificationCompat.Builder(this, ChannelId)
.setSmallIcon(R.drawable.ic_launcher_background) .setSmallIcon(R.drawable.ic_launcher_background)
.setContentTitle(strings.readAloud) .setContentTitle(AndroidReadAloudEngine.notificationTitle())
.setContentText(strings.readingInBackground) .setContentText(strings.readingInBackground)
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
.setOngoing(state.playing) .setOngoing(state.playing)

View File

@ -60,6 +60,7 @@ data class PlatformOpenBookRequest(
data class ReadingPosition( data class ReadingPosition(
val itemIndex: Int, val itemIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val readAloudSentenceIndex: Int? = null,
) )
data class BookInfoExtras( data class BookInfoExtras(

View File

@ -1020,11 +1020,17 @@ private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) {
.filter { it.readingStatus == BookReadingStatus.NEW } .filter { it.readingStatus == BookReadingStatus.NEW }
.mapTo(mutableSetOf()) { it.fileId } .mapTo(mutableSetOf()) { it.fileId }
return when (this) { return when (this) {
ReadingNow -> sourceItems.filter { it.readingStatus == BookReadingStatus.READING } ReadingNow -> sourceItems
.filter { it.readingStatus == BookReadingStatus.READING }
.sortedByLastReadThenTitle()
RecentlyAdded -> if (searchActive) { RecentlyAdded -> if (searchActive) {
sourceItems.filter { it.fileId in recentlyAddedIds && it.readingStatus == BookReadingStatus.NEW } sourceItems
.filter { it.fileId in recentlyAddedIds && it.readingStatus == BookReadingStatus.NEW }
.sortedByImportedThenTitle()
} else { } else {
recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW } recentlyAddedItems
.filter { it.readingStatus == BookReadingStatus.NEW }
.sortedByImportedThenTitle()
} }
MyLibrary -> sourceItems.filter { MyLibrary -> sourceItems.filter {
it.fileId !in recentlyAddedIds && it.fileId !in recentlyAddedIds &&
@ -1032,15 +1038,77 @@ private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) {
it.readingStatus != BookReadingStatus.TO_READ && it.readingStatus != BookReadingStatus.TO_READ &&
it.readingStatus != BookReadingStatus.READ && it.readingStatus != BookReadingStatus.READ &&
it.readingStatus != BookReadingStatus.NOT_INTERESTED it.readingStatus != BookReadingStatus.NOT_INTERESTED
} }.sortedByTitleNaturally()
ToRead -> sourceItems.filter { it.readingStatus == BookReadingStatus.TO_READ } ToRead -> sourceItems
Favorites -> sourceItems.filter { it.favorite } .filter { it.readingStatus == BookReadingStatus.TO_READ }
Read -> sourceItems.filter { it.readingStatus == BookReadingStatus.READ } .sortedByLastReadThenTitle()
NotInterested -> sourceItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } Favorites -> sourceItems
.filter { it.favorite }
.sortedByLastReadThenTitle()
Read -> sourceItems
.filter { it.readingStatus == BookReadingStatus.READ }
.sortedByTitleNaturally()
NotInterested -> sourceItems
.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
.sortedByTitleNaturally()
} }
} }
} }
private fun List<LibraryItem>.sortedByLastReadThenTitle(): List<LibraryItem> =
sortedWith(
compareByDescending<LibraryItem> { it.lastReadAt ?: Long.MIN_VALUE }
.then(LibraryItemNaturalTitleComparator)
)
private fun List<LibraryItem>.sortedByImportedThenTitle(): List<LibraryItem> =
sortedWith(
compareByDescending<LibraryItem> { it.importedAt ?: Long.MIN_VALUE }
.then(LibraryItemNaturalTitleComparator)
)
private fun List<LibraryItem>.sortedByTitleNaturally(): List<LibraryItem> =
sortedWith(LibraryItemNaturalTitleComparator)
private val LibraryItemNaturalTitleComparator = Comparator<LibraryItem> { left, right ->
val titleCompare = naturalCompare(left.title, right.title)
if (titleCompare != 0) titleCompare else left.fileId.compareTo(right.fileId)
}
private fun naturalCompare(left: String, right: String): Int {
var leftIndex = 0
var rightIndex = 0
var exactFallback = 0
while (leftIndex < left.length && rightIndex < right.length) {
val leftChar = left[leftIndex]
val rightChar = right[rightIndex]
if (leftChar.isDigit() && rightChar.isDigit()) {
val leftStart = leftIndex
val rightStart = rightIndex
while (leftIndex < left.length && left[leftIndex].isDigit()) leftIndex += 1
while (rightIndex < right.length && right[rightIndex].isDigit()) rightIndex += 1
val leftDigits = left.substring(leftStart, leftIndex).trimStart('0')
val rightDigits = right.substring(rightStart, rightIndex).trimStart('0')
val leftNumber = leftDigits.ifEmpty { "0" }
val rightNumber = rightDigits.ifEmpty { "0" }
val lengthCompare = leftNumber.length.compareTo(rightNumber.length)
if (lengthCompare != 0) return lengthCompare
val numberCompare = leftNumber.compareTo(rightNumber)
if (numberCompare != 0) return numberCompare
val rawLengthCompare = (leftIndex - leftStart).compareTo(rightIndex - rightStart)
if (rawLengthCompare != 0) return rawLengthCompare
} else {
val foldedCompare = leftChar.lowercaseChar().compareTo(rightChar.lowercaseChar())
if (foldedCompare != 0) return foldedCompare
if (exactFallback == 0) exactFallback = leftChar.compareTo(rightChar)
leftIndex += 1
rightIndex += 1
}
}
return left.length.compareTo(right.length).takeIf { it != 0 } ?: exactFallback
}
private val LibraryFilter.label: String private val LibraryFilter.label: String
get() = when (this) { get() = when (this) {
LibraryFilter.ReadingNow -> strings.filterReadingNow LibraryFilter.ReadingNow -> strings.filterReadingNow

View File

@ -45,7 +45,7 @@ expect object ReadAloudPlatform {
val state: StateFlow<ReadAloudState> val state: StateFlow<ReadAloudState>
val settingsState: StateFlow<ReadAloudSettingsState> val settingsState: StateFlow<ReadAloudSettingsState>
fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) fun prepare(fileId: String, bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int)
fun play() fun play()
fun stop() fun stop()
fun skip(delta: Int) fun skip(delta: Int)

View File

@ -47,6 +47,9 @@ import androidx.compose.ui.input.pointer.PointerEventPass
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
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -88,24 +91,38 @@ internal fun ContinuousBookReader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) }, contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null, highlightedSentence: ReadAloudSentence? = null,
onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
) { ) {
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() } val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
val contentPadding = PaddingValues(6.dp) val contentPadding = PaddingValues(6.dp)
val userScrollConnection = remember(onUserScroll) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput && available.y != 0f) {
onUserScroll()
}
return Offset.Zero
}
}
}
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.nestedScroll(userScrollConnection)
.pageTurnOnTouchTap( .pageTurnOnTouchTap(
onPageDown = { onPageDown = {
onUserScroll()
scope.launch { scope.launch {
listState.pageScrollByPage(1, textLineMetricsByItem) listState.pageScrollByPage(1, textLineMetricsByItem)
} }
}, },
onPageUp = { onPageUp = {
onUserScroll()
scope.launch { scope.launch {
listState.pageScrollByPage(-1, textLineMetricsByItem) listState.pageScrollByPage(-1, textLineMetricsByItem)
} }
@ -750,6 +767,11 @@ internal data class ReaderContentPlan(
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
?: sentences.lastOrNull()?.index ?: sentences.lastOrNull()?.index
?: 0 ?: 0
fun resumeSentenceIndex(position: ReadingPosition): Int =
position.readAloudSentenceIndex
?.takeIf { index -> sentences.getOrNull(index)?.itemIndex == position.itemIndex }
?: sentenceIndexAtOrAfterItem(position.itemIndex)
} }
internal sealed interface ReaderElement { internal sealed interface ReaderElement {

View File

@ -84,15 +84,19 @@ internal fun BookView(
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) } var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android") val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java") val showViewFileAction = platformName.startsWith("Java")
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty() val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val highlightedSentence = readAloudState.sentenceIndex val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) } ?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudPanelVisible && readAloudState.active } ?.takeIf { readAloudState.active }
val highlightedSentence = activeReadAloudSentence
?.takeIf { readAloudPanelVisible }
fun showMessage(message: String) { fun showMessage(message: String) {
scope.launch { scope.launch {
@ -130,19 +134,30 @@ internal fun BookView(
LaunchedEffect(fileId) { LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position -> loadLibraryReadingPosition(fileId)?.let { position ->
readAloudResumeSentenceIndex = position.readAloudSentenceIndex
listState.scrollToItem(position.itemIndex, position.scrollOffset) listState.scrollToItem(position.itemIndex, position.scrollOffset)
} }
restored = true restored = true
} }
LaunchedEffect(fileId, listState) { LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
if (readAloudState.active) return@LaunchedEffect
snapshotFlow { snapshotFlow {
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) ReadingPosition(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
} }
.filter { restored } .filter { restored && userScrollGeneration > 0 }
.distinctUntilChanged() .distinctUntilChanged()
.debounce(750) .debounce(750)
.collect { saveLibraryReadingPosition(fileId, it) } .collect { position ->
saveLibraryReadingPosition(
fileId,
position.copy(readAloudSentenceIndex = readAloudResumeSentenceIndex),
)
}
} }
LaunchedEffect(fileId, listState) { LaunchedEffect(fileId, listState) {
@ -160,12 +175,13 @@ internal fun BookView(
} }
} }
LaunchedEffect(readAloudState.sentenceIndex) { LaunchedEffect(fileId, readAloudState.active, readAloudState.sentenceIndex) {
val itemIndex = highlightedSentence?.itemIndex ?: return@LaunchedEffect val sentence = activeReadAloudSentence ?: return@LaunchedEffect
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0)) readAloudResumeSentenceIndex = sentence.index
val visibleItems = listState.layoutInfo.visibleItemsInfo val itemIndex = sentence.itemIndex
if (visibleItems.none { it.index == itemIndex }) { saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index))
listState.animateScrollToItem(itemIndex) if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) {
listState.animateScrollToItem(itemIndex, 0)
} }
} }
@ -225,8 +241,13 @@ internal fun BookView(
}, },
showReadAloudAction = showReadAloudAction, showReadAloudAction = showReadAloudAction,
onReadAloud = { onReadAloud = {
val startIndex = contentPlan.sentenceIndexAtOrAfterItem(listState.firstVisibleItemIndex) val position = ReadingPosition(
ReadAloudPlatform.prepare(book.title, contentPlan.sentences, startIndex) listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
val startIndex = contentPlan.resumeSentenceIndex(position)
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
readAloudPanelVisible = true readAloudPanelVisible = true
ReadAloudPlatform.play() ReadAloudPlatform.play()
}, },
@ -273,6 +294,7 @@ internal fun BookView(
listState = listState, listState = listState,
contentPlan = contentPlan, contentPlan = contentPlan,
highlightedSentence = highlightedSentence, highlightedSentence = highlightedSentence,
onUserScroll = { userScrollGeneration += 1 },
onImageOpen = onImageOpen, onImageOpen = onImageOpen,
) )
if (readAloudPanelVisible && readAloudState.active) { if (readAloudPanelVisible && readAloudState.active) {

View File

@ -574,12 +574,17 @@ private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log") File(System.getProperty("user.home"), ".toread/toread.log")
private fun ReadingPosition.toFormatHintsJson(): String = private fun ReadingPosition.toFormatHintsJson(): String =
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}""" buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
append("}")
}
private fun String.toReadingPosition(): ReadingPosition? { private fun String.toReadingPosition(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset) else null val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
} }
private fun String.toSearchPrefixes(): List<String> = private fun String.toSearchPrefixes(): List<String> =

View File

@ -10,7 +10,7 @@ actual object ReadAloudPlatform {
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit actual fun prepare(fileId: String, bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
actual fun play() = Unit actual fun play() = Unit
actual fun stop() = Unit actual fun stop() = Unit
actual fun skip(delta: Int) = Unit actual fun skip(delta: Int) = Unit

View File

@ -10,7 +10,7 @@ actual object ReadAloudPlatform {
private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState())
actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState actual val settingsState: StateFlow<ReadAloudSettingsState> = mutableSettingsState
actual fun prepare(bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit actual fun prepare(fileId: String, bookTitle: String, sentences: List<ReadAloudSentence>, startIndex: Int) = Unit
actual fun play() = Unit actual fun play() = Unit
actual fun stop() = Unit actual fun stop() = Unit
actual fun skip(delta: Int) = Unit actual fun skip(delta: Int) = Unit

View File

@ -702,21 +702,14 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
FROM book_files f FROM book_files f
LEFT JOIN books b ON b.id = f.book_id LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL WHERE f.duplicate_of_file_id IS NULL
ORDER BY
CASE
WHEN f.reading_status = 'READING' THEN 0
WHEN f.reading_status = 'NOT_INTERESTED' THEN 2
ELSE 1
END,
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
f.id
LIMIT ? OFFSET ?
""".trimIndent() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setInt(1, limit) statement.executeQuery().use { resultSet ->
statement.setInt(2, offset) resultSet.mapRows { it.toLibraryFileRecord() }
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toLibraryFileRecord() } } .sortedWith(LibraryFileNaturalTitleComparator)
.drop(offset)
.take(limit)
}
} }
} }
@ -1097,6 +1090,50 @@ private fun String?.matchPositions(prefixes: List<String>, fieldOffset: Int): Li
private val SearchWordRegex = Regex("""[\p{L}\p{N}]+""") private val SearchWordRegex = Regex("""[\p{L}\p{N}]+""")
private val LibraryFileNaturalTitleComparator = Comparator<LibraryFileRecord> { left, right ->
val titleCompare = naturalCompare(left.sortTitle(), right.sortTitle())
if (titleCompare != 0) titleCompare else left.fileId.compareTo(right.fileId)
}
private fun LibraryFileRecord.sortTitle(): String =
title?.takeIf(String::isNotBlank)
?: originalFilename?.takeIf(String::isNotBlank)
?: fileId
private fun naturalCompare(left: String, right: String): Int {
var leftIndex = 0
var rightIndex = 0
var exactFallback = 0
while (leftIndex < left.length && rightIndex < right.length) {
val leftChar = left[leftIndex]
val rightChar = right[rightIndex]
if (leftChar.isDigit() && rightChar.isDigit()) {
val leftStart = leftIndex
val rightStart = rightIndex
while (leftIndex < left.length && left[leftIndex].isDigit()) leftIndex += 1
while (rightIndex < right.length && right[rightIndex].isDigit()) rightIndex += 1
val leftDigits = left.substring(leftStart, leftIndex).trimStart('0')
val rightDigits = right.substring(rightStart, rightIndex).trimStart('0')
val leftNumber = leftDigits.ifEmpty { "0" }
val rightNumber = rightDigits.ifEmpty { "0" }
val lengthCompare = leftNumber.length.compareTo(rightNumber.length)
if (lengthCompare != 0) return lengthCompare
val numberCompare = leftNumber.compareTo(rightNumber)
if (numberCompare != 0) return numberCompare
val rawLengthCompare = (leftIndex - leftStart).compareTo(rightIndex - rightStart)
if (rawLengthCompare != 0) return rawLengthCompare
} else {
val foldedCompare = leftChar.lowercaseChar().compareTo(rightChar.lowercaseChar())
if (foldedCompare != 0) return foldedCompare
if (exactFallback == 0) exactFallback = leftChar.compareTo(rightChar)
leftIndex += 1
rightIndex += 1
}
}
return left.length.compareTo(right.length).takeIf { it != 0 } ?: exactFallback
}
private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord( private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
fileId = getString("file_id"), fileId = getString("file_id"),
bookId = getString("book_id"), bookId = getString("book_id"),

View File

@ -161,17 +161,16 @@ class H2LibraryDatabaseTest {
} }
@Test @Test
fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() { fun listsLibraryFilesByNaturalTitle() {
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle") val db = H2LibraryDatabase.openMemory("listsLibraryFilesByNaturalTitle")
val now = 1_700_000_000_000L val now = 1_700_000_000_000L
db.transaction { db.transaction {
listOf( listOf(
Triple("book-beta", "Beta", BookReadingStatus.NEW), Triple("book-10", "Book 10", BookReadingStatus.READING),
Triple("book-alpha", "Alpha", BookReadingStatus.READ), Triple("book-alpha", "Alpha", BookReadingStatus.READ),
Triple("book-gamma", "Gamma", BookReadingStatus.READING), Triple("book-2", "Book 2", BookReadingStatus.NEW),
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING), Triple("book-beta", "Beta", BookReadingStatus.NOT_INTERESTED),
Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED),
).forEachIndexed { index, (bookId, title, status) -> ).forEachIndexed { index, (bookId, title, status) ->
books.upsert( books.upsert(
BookRecord( BookRecord(
@ -189,7 +188,7 @@ class H2LibraryDatabaseTest {
originalFilename = "$title.fb2", originalFilename = "$title.fb2",
storageKind = BookFileStorageKind.EXTERNAL_URI, storageKind = BookFileStorageKind.EXTERNAL_URI,
readingStatus = status, readingStatus = status,
lastReadAt = if (title == "Aardvark") now + 500 else now + 100, lastReadAt = now + index,
createdAt = now + index, createdAt = now + index,
updatedAt = now + index, updatedAt = now + index,
) )
@ -198,7 +197,7 @@ class H2LibraryDatabaseTest {
} }
assertEquals( assertEquals(
listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"), listOf("Alpha", "Beta", "Book 2", "Book 10"),
db.files.listLibraryFiles().map { it.title }, db.files.listLibraryFiles().map { it.title },
) )
db.close() db.close()