UI/TTS improvements

This commit is contained in:
sergeych committed 2026-05-23 21:55:34 +03:00
1 parent 0eba07b594
commit ed66ea0d55
13 files changed
+250 -57

No files matched your search

@@ -60,6 +60,7 @@ data class PlatformOpenBookRequest(
data class ReadingPosition(
val itemIndex: Int,
val scrollOffset: Int,
val readAloudSentenceIndex: Int? = null,
)
data class BookInfoExtras(
@@ -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<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
get() = when (this) {
LibraryFilter.ReadingNow -> strings.filterReadingNow
@@ -45,7 +45,7 @@ expect object ReadAloudPlatform {
val state: StateFlow<ReadAloudState>
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 stop()
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.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<Int, TextLineMetrics>() }
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 {
@@ -84,15 +84,19 @@ internal fun BookView(
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible 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 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) {