UI/TTS improvements
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user