UI/TTS improvements
This commit is contained in:
parent
0eba07b594
commit
ed66ea0d55
@ -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 {
|
||||||
|
|||||||
@ -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> =
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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> =
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user