diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8cd26cb..c5ea99e 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.0" -val appVersionCode = 1 +val appVersionCode = 2 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 433e407..5fa63af 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -777,12 +777,17 @@ private fun libraryLogFile(): File = File(appContext.filesDir, "logs/toread.log") 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? { 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() - 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 = 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 51edc4f..a330bc2 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt @@ -19,6 +19,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import java.util.Locale +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -26,15 +27,17 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock actual object ReadAloudPlatform { actual val isSupported: Boolean = true actual val state: StateFlow = AndroidReadAloudEngine.state actual val settingsState: StateFlow = AndroidReadAloudEngine.settingsState - actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) { + actual fun prepare(fileId: String, bookTitle: String, sentences: List, startIndex: Int) { val context = androidAppContext() ?: return - AndroidReadAloudEngine.prepare(context, bookTitle, sentences, startIndex) + AndroidReadAloudEngine.prepare(context, fileId, bookTitle, sentences, startIndex) ReadAloudService.start(context) } @@ -83,6 +86,9 @@ private object AndroidReadAloudEngine { val state: StateFlow = mutableState private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) val settingsState: StateFlow = mutableSettingsState + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val positionSaveMutex = Mutex() + private val positionSaveVersion = AtomicLong() private var tts: TextToSpeech? = null private var ttsReady = false @@ -90,11 +96,13 @@ private object AndroidReadAloudEngine { private var engineProbe: TextToSpeech? = null private var voiceProbe: TextToSpeech? = null private var shouldSpeakWhenReady = false + private var fileId: String? = null private var bookTitle: String = "" private var sentences: List = emptyList() private var currentIndex: Int = 0 - fun prepare(context: Context, title: String, queue: List, startIndex: Int) { + fun prepare(context: Context, fileId: String, title: String, queue: List, startIndex: Int) { + this.fileId = fileId bookTitle = title sentences = queue currentIndex = startIndex.coerceIn(queue.indices.takeIf { queue.isNotEmpty() } ?: 0..0) @@ -111,7 +119,11 @@ private object AndroidReadAloudEngine { ensureTts(context.applicationContext) if (!ttsReady) { 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 } speakCurrent() @@ -127,11 +139,13 @@ private object AndroidReadAloudEngine { if (sentences.isEmpty()) return val wasPlaying = mutableState.value.playing currentIndex = (currentIndex + delta).coerceIn(sentences.indices) + val sentence = sentences.getOrNull(currentIndex) mutableState.value = mutableState.value.copy( active = true, playing = wasPlaying, - sentenceIndex = currentIndex, + sentenceIndex = sentence?.index ?: currentIndex, ) + sentence?.let(::saveSentencePosition) if (wasPlaying && ttsReady) { speakCurrent() } @@ -262,6 +276,7 @@ private object AndroidReadAloudEngine { return } 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") } @@ -288,6 +303,10 @@ private object AndroidReadAloudEngine { return } if (speakIndex != null && currentSentence.pauseAfterMillis > 0) { + sentences.getOrNull(currentIndex + 1)?.let { nextSentence -> + mutableState.value = mutableState.value.copy(sentenceIndex = nextSentence.index) + saveSentencePosition(nextSentence) + } return } 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?) { voiceProbe?.shutdown() var probe: TextToSpeech? = null @@ -448,7 +482,7 @@ class ReadAloudService : Service() { return NotificationCompat.Builder(this, ChannelId) .setSmallIcon(R.drawable.ic_launcher_background) - .setContentTitle(strings.readAloud) + .setContentTitle(AndroidReadAloudEngine.notificationTitle()) .setContentText(strings.readingInBackground) .setContentIntent(contentIntent) .setOngoing(state.playing) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 49ff594..5179e48 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -60,6 +60,7 @@ data class PlatformOpenBookRequest( data class ReadingPosition( val itemIndex: Int, val scrollOffset: Int, + val readAloudSentenceIndex: Int? = null, ) data class BookInfoExtras( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 87ae5da..13dc946 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -1020,11 +1020,17 @@ private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) { .filter { it.readingStatus == BookReadingStatus.NEW } .mapTo(mutableSetOf()) { it.fileId } return when (this) { - ReadingNow -> sourceItems.filter { it.readingStatus == BookReadingStatus.READING } + ReadingNow -> sourceItems + .filter { it.readingStatus == BookReadingStatus.READING } + .sortedByLastReadThenTitle() 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 { - recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW } + recentlyAddedItems + .filter { it.readingStatus == BookReadingStatus.NEW } + .sortedByImportedThenTitle() } MyLibrary -> sourceItems.filter { it.fileId !in recentlyAddedIds && @@ -1032,15 +1038,77 @@ private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) { it.readingStatus != BookReadingStatus.TO_READ && it.readingStatus != BookReadingStatus.READ && it.readingStatus != BookReadingStatus.NOT_INTERESTED - } - ToRead -> sourceItems.filter { it.readingStatus == BookReadingStatus.TO_READ } - Favorites -> sourceItems.filter { it.favorite } - Read -> sourceItems.filter { it.readingStatus == BookReadingStatus.READ } - NotInterested -> sourceItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } + }.sortedByTitleNaturally() + ToRead -> sourceItems + .filter { it.readingStatus == BookReadingStatus.TO_READ } + .sortedByLastReadThenTitle() + 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.sortedByLastReadThenTitle(): List = + sortedWith( + compareByDescending { it.lastReadAt ?: Long.MIN_VALUE } + .then(LibraryItemNaturalTitleComparator) + ) + +private fun List.sortedByImportedThenTitle(): List = + sortedWith( + compareByDescending { it.importedAt ?: Long.MIN_VALUE } + .then(LibraryItemNaturalTitleComparator) + ) + +private fun List.sortedByTitleNaturally(): List = + sortedWith(LibraryItemNaturalTitleComparator) + +private val LibraryItemNaturalTitleComparator = Comparator { 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 get() = when (this) { LibraryFilter.ReadingNow -> strings.filterReadingNow diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt index e666fd8..69dafc0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt @@ -45,7 +45,7 @@ expect object ReadAloudPlatform { val state: StateFlow val settingsState: StateFlow - fun prepare(bookTitle: String, sentences: List, startIndex: Int) + fun prepare(fileId: String, bookTitle: String, sentences: List, startIndex: Int) fun play() fun stop() fun skip(delta: Int) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index b4e7dd8..a24a311 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -47,6 +47,9 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput 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.SpanStyle import androidx.compose.ui.text.TextStyle @@ -88,24 +91,38 @@ internal fun ContinuousBookReader( modifier: Modifier = Modifier, contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) }, highlightedSentence: ReadAloudSentence? = null, + onUserScroll: () -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {}, ) { val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf() } 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( state = listState, modifier = modifier .background(MaterialTheme.colorScheme.surface) + .nestedScroll(userScrollConnection) .pageTurnOnTouchTap( onPageDown = { + onUserScroll() scope.launch { listState.pageScrollByPage(1, textLineMetricsByItem) } }, onPageUp = { + onUserScroll() scope.launch { listState.pageScrollByPage(-1, textLineMetricsByItem) } @@ -750,6 +767,11 @@ internal data class ReaderContentPlan( sentences.firstOrNull { it.itemIndex >= itemIndex }?.index ?: sentences.lastOrNull()?.index ?: 0 + + fun resumeSentenceIndex(position: ReadingPosition): Int = + position.readAloudSentenceIndex + ?.takeIf { index -> sentences.getOrNull(index)?.itemIndex == position.itemIndex } + ?: sentenceIndexAtOrAfterItem(position.itemIndex) } internal sealed interface ReaderElement { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index ce15706..8591de4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -84,15 +84,19 @@ internal fun BookView( var libraryItem by remember(fileId) { mutableStateOf(null) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } + var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf(null) } + var userScrollGeneration by remember(fileId) { mutableStateOf(0) } val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val platformName = getPlatform().name val showShareAction = platformName.startsWith("Android") val showViewFileAction = platformName.startsWith("Java") val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty() - val highlightedSentence = readAloudState.sentenceIndex + val activeReadAloudSentence = readAloudState.sentenceIndex ?.let { index -> contentPlan.sentences.getOrNull(index) } - ?.takeIf { readAloudPanelVisible && readAloudState.active } + ?.takeIf { readAloudState.active } + val highlightedSentence = activeReadAloudSentence + ?.takeIf { readAloudPanelVisible } fun showMessage(message: String) { scope.launch { @@ -130,19 +134,30 @@ internal fun BookView( LaunchedEffect(fileId) { loadLibraryReadingPosition(fileId)?.let { position -> + readAloudResumeSentenceIndex = position.readAloudSentenceIndex listState.scrollToItem(position.itemIndex, position.scrollOffset) } restored = true } - LaunchedEffect(fileId, listState) { + LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) { + if (readAloudState.active) return@LaunchedEffect snapshotFlow { - ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) + ReadingPosition( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + readAloudResumeSentenceIndex, + ) } - .filter { restored } + .filter { restored && userScrollGeneration > 0 } .distinctUntilChanged() .debounce(750) - .collect { saveLibraryReadingPosition(fileId, it) } + .collect { position -> + saveLibraryReadingPosition( + fileId, + position.copy(readAloudSentenceIndex = readAloudResumeSentenceIndex), + ) + } } LaunchedEffect(fileId, listState) { @@ -160,12 +175,13 @@ internal fun BookView( } } - LaunchedEffect(readAloudState.sentenceIndex) { - val itemIndex = highlightedSentence?.itemIndex ?: return@LaunchedEffect - saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0)) - val visibleItems = listState.layoutInfo.visibleItemsInfo - if (visibleItems.none { it.index == itemIndex }) { - listState.animateScrollToItem(itemIndex) + LaunchedEffect(fileId, readAloudState.active, readAloudState.sentenceIndex) { + val sentence = activeReadAloudSentence ?: return@LaunchedEffect + readAloudResumeSentenceIndex = sentence.index + val itemIndex = sentence.itemIndex + saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index)) + if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) { + listState.animateScrollToItem(itemIndex, 0) } } @@ -225,8 +241,13 @@ internal fun BookView( }, showReadAloudAction = showReadAloudAction, onReadAloud = { - val startIndex = contentPlan.sentenceIndexAtOrAfterItem(listState.firstVisibleItemIndex) - ReadAloudPlatform.prepare(book.title, contentPlan.sentences, startIndex) + val position = ReadingPosition( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + readAloudResumeSentenceIndex, + ) + val startIndex = contentPlan.resumeSentenceIndex(position) + ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) readAloudPanelVisible = true ReadAloudPlatform.play() }, @@ -273,6 +294,7 @@ internal fun BookView( listState = listState, contentPlan = contentPlan, highlightedSentence = highlightedSentence, + onUserScroll = { userScrollGeneration += 1 }, onImageOpen = onImageOpen, ) if (readAloudPanelVisible && readAloudState.active) { 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 68fa670..ec71a33 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -574,12 +574,17 @@ private fun libraryLogFile(): File = File(System.getProperty("user.home"), ".toread/toread.log") 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? { 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() - 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 = diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt index d5ca2ea..6dba933 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/ReadAloudPlatform.jvm.kt @@ -10,7 +10,7 @@ actual object ReadAloudPlatform { private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) actual val settingsState: StateFlow = mutableSettingsState - actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit + actual fun prepare(fileId: String, bookTitle: String, sentences: List, startIndex: Int) = Unit actual fun play() = Unit actual fun stop() = Unit actual fun skip(delta: Int) = Unit diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt index d5ca2ea..6dba933 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/ReadAloudPlatform.web.kt @@ -10,7 +10,7 @@ actual object ReadAloudPlatform { private val mutableSettingsState = MutableStateFlow(ReadAloudSettingsState()) actual val settingsState: StateFlow = mutableSettingsState - actual fun prepare(bookTitle: String, sentences: List, startIndex: Int) = Unit + actual fun prepare(fileId: String, bookTitle: String, sentences: List, startIndex: Int) = Unit actual fun play() = Unit actual fun stop() = Unit actual fun skip(delta: Int) = Unit diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index 84da4a7..7229d51 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -702,21 +702,14 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF FROM book_files f LEFT JOIN books b ON b.id = f.book_id 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() ).use { statement -> - statement.setInt(1, limit) - statement.setInt(2, offset) - statement.executeQuery().use { resultSet -> 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, fieldOffset: Int): Li private val SearchWordRegex = Regex("""[\p{L}\p{N}]+""") +private val LibraryFileNaturalTitleComparator = Comparator { 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( fileId = getString("file_id"), bookId = getString("book_id"), diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt index 363ba31..d640aad 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt @@ -161,17 +161,16 @@ class H2LibraryDatabaseTest { } @Test - fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() { - val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle") + fun listsLibraryFilesByNaturalTitle() { + val db = H2LibraryDatabase.openMemory("listsLibraryFilesByNaturalTitle") val now = 1_700_000_000_000L db.transaction { listOf( - Triple("book-beta", "Beta", BookReadingStatus.NEW), + Triple("book-10", "Book 10", BookReadingStatus.READING), Triple("book-alpha", "Alpha", BookReadingStatus.READ), - Triple("book-gamma", "Gamma", BookReadingStatus.READING), - Triple("book-aardvark", "Aardvark", BookReadingStatus.READING), - Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED), + Triple("book-2", "Book 2", BookReadingStatus.NEW), + Triple("book-beta", "Beta", BookReadingStatus.NOT_INTERESTED), ).forEachIndexed { index, (bookId, title, status) -> books.upsert( BookRecord( @@ -189,7 +188,7 @@ class H2LibraryDatabaseTest { originalFilename = "$title.fb2", storageKind = BookFileStorageKind.EXTERNAL_URI, readingStatus = status, - lastReadAt = if (title == "Aardvark") now + 500 else now + 100, + lastReadAt = now + index, createdAt = now + index, updatedAt = now + index, ) @@ -198,7 +197,7 @@ class H2LibraryDatabaseTest { } assertEquals( - listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"), + listOf("Alpha", "Beta", "Book 2", "Book 10"), db.files.listLibraryFiles().map { it.title }, ) db.close()