contents for reading
This commit is contained in:
parent
cde4d89eed
commit
7681590992
@ -832,16 +832,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
|
|||||||
buildString {
|
buildString {
|
||||||
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
||||||
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
||||||
|
if (backStack.isNotEmpty()) {
|
||||||
|
append(""","backStack":[""")
|
||||||
|
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
|
||||||
|
append("]")
|
||||||
|
}
|
||||||
append("}")
|
append("}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toReadingPosition(): ReadingPosition? {
|
private fun String.toReadingPosition(): ReadingPosition? {
|
||||||
|
val position = toReadingPositionObject() ?: return null
|
||||||
|
val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1)
|
||||||
|
val backStack = backStackJson
|
||||||
|
?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() }
|
||||||
|
.orEmpty()
|
||||||
|
.take(MaxStoredReadingBackStack)
|
||||||
|
return position.copy(backStack = backStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toReadingPositionObject(): 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()
|
||||||
val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
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
|
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""")
|
||||||
|
private const val MaxStoredReadingBackStack = 10
|
||||||
|
|
||||||
private fun String.toSearchPrefixes(): List<String> =
|
private fun String.toSearchPrefixes(): List<String> =
|
||||||
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@ data class ReadingPosition(
|
|||||||
val itemIndex: Int,
|
val itemIndex: Int,
|
||||||
val scrollOffset: Int,
|
val scrollOffset: Int,
|
||||||
val readAloudSentenceIndex: Int? = null,
|
val readAloudSentenceIndex: Int? = null,
|
||||||
|
val backStack: List<ReadingPosition> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ReaderFontSettings(
|
data class ReaderFontSettings(
|
||||||
|
|||||||
@ -133,6 +133,8 @@ internal open class AppStrings {
|
|||||||
open val noImage = "No image"
|
open val noImage = "No image"
|
||||||
|
|
||||||
open val readerTheme = "Theme"
|
open val readerTheme = "Theme"
|
||||||
|
open val tableOfContents = "Contents"
|
||||||
|
open val backToLastPosition = "Back"
|
||||||
open val readAloud = "Read aloud"
|
open val readAloud = "Read aloud"
|
||||||
open val readerMenu = "Book reader menu"
|
open val readerMenu = "Book reader menu"
|
||||||
open val info = "Info..."
|
open val info = "Info..."
|
||||||
@ -337,6 +339,8 @@ internal object RussianStrings : AppStrings() {
|
|||||||
override val noImage = "Нет изображения"
|
override val noImage = "Нет изображения"
|
||||||
|
|
||||||
override val readerTheme = "Тема"
|
override val readerTheme = "Тема"
|
||||||
|
override val tableOfContents = "Содержание"
|
||||||
|
override val backToLastPosition = "Назад"
|
||||||
override val readAloud = "Читать вслух"
|
override val readAloud = "Читать вслух"
|
||||||
override val readerMenu = "Меню чтения"
|
override val readerMenu = "Меню чтения"
|
||||||
override val info = "Информация..."
|
override val info = "Информация..."
|
||||||
|
|||||||
@ -1463,6 +1463,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
|
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
|
||||||
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
|
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
|
||||||
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
|
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
|
||||||
|
lateinit var addSections: (List<Fb2Section>, Int) -> Unit
|
||||||
|
val tocEntries = mutableListOf<ReaderTocEntry>()
|
||||||
|
|
||||||
addPoemSentences = { itemIndex, poem ->
|
addPoemSentences = { itemIndex, poem ->
|
||||||
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
|
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
|
||||||
@ -1497,18 +1499,20 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
|
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSection(section: Fb2Section, readerDepth: Int) {
|
fun addSection(section: Fb2Section, readerDepth: Int, fallbackTitle: String) {
|
||||||
|
val title = section.title?.ifBlank { null } ?: fallbackTitle
|
||||||
|
val itemIndex = elements.size
|
||||||
|
tocEntries += ReaderTocEntry(title, itemIndex, readerDepth)
|
||||||
if (section.title.isNullOrBlank()) {
|
if (section.title.isNullOrBlank()) {
|
||||||
elements += ReaderElement.SectionSeparator
|
elements += ReaderElement.SectionSeparator
|
||||||
} else {
|
} else {
|
||||||
val itemIndex = elements.size
|
elements += ReaderElement.SectionTitle(title, readerDepth)
|
||||||
elements += ReaderElement.SectionTitle(section.title!!, readerDepth)
|
|
||||||
sentences += ReadAloudSentence(
|
sentences += ReadAloudSentence(
|
||||||
index = sentences.size,
|
index = sentences.size,
|
||||||
itemIndex = itemIndex,
|
itemIndex = itemIndex,
|
||||||
start = 0,
|
start = 0,
|
||||||
endExclusive = section.title!!.length,
|
endExclusive = title.length,
|
||||||
text = section.title!!,
|
text = title,
|
||||||
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
|
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
|
||||||
pauseAfterMillis = HeadingPauseAfterMillis,
|
pauseAfterMillis = HeadingPauseAfterMillis,
|
||||||
)
|
)
|
||||||
@ -1548,7 +1552,13 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1
|
val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1
|
||||||
section.sections.forEach { addSection(it, childDepth) }
|
addSections(section.sections, childDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSections = { sections, readerDepth ->
|
||||||
|
sections.forEachIndexed { index, section ->
|
||||||
|
addSection(section, readerDepth, strings.sectionFallback(index))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
elements += ReaderElement.Cover
|
elements += ReaderElement.Cover
|
||||||
@ -1558,15 +1568,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
addEpigraphSentences(itemIndex, epigraph)
|
addEpigraphSentences(itemIndex, epigraph)
|
||||||
elements += ReaderElement.Epigraph(epigraph, 0)
|
elements += ReaderElement.Epigraph(epigraph, 0)
|
||||||
}
|
}
|
||||||
book.sections.forEach { addSection(it, 0) }
|
addSections(book.sections, 0)
|
||||||
elements += ReaderElement.FixedSpacer(22)
|
elements += ReaderElement.FixedSpacer(22)
|
||||||
|
|
||||||
return ReaderContentPlan(elements, sentences)
|
return ReaderContentPlan(elements, sentences, tocEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class ReaderContentPlan(
|
internal data class ReaderContentPlan(
|
||||||
val elements: List<ReaderElement>,
|
val elements: List<ReaderElement>,
|
||||||
val sentences: List<ReadAloudSentence>,
|
val sentences: List<ReadAloudSentence>,
|
||||||
|
val tocEntries: List<ReaderTocEntry>,
|
||||||
) {
|
) {
|
||||||
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
|
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
|
||||||
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
|
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
|
||||||
@ -1579,6 +1590,12 @@ internal data class ReaderContentPlan(
|
|||||||
?: sentenceIndexAtOrAfterItem(position.itemIndex)
|
?: sentenceIndexAtOrAfterItem(position.itemIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ReaderTocEntry(
|
||||||
|
val title: String,
|
||||||
|
val itemIndex: Int,
|
||||||
|
val depth: Int,
|
||||||
|
)
|
||||||
|
|
||||||
internal sealed interface ReaderElement {
|
internal sealed interface ReaderElement {
|
||||||
data object Cover : ReaderElement
|
data object Cover : ReaderElement
|
||||||
data class FixedSpacer(val heightDp: Int) : ReaderElement
|
data class FixedSpacer(val heightDp: Int) : ReaderElement
|
||||||
|
|||||||
@ -1,21 +1,26 @@
|
|||||||
package net.sergeych.toread
|
package net.sergeych.toread
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.BookmarkAdd
|
import androidx.compose.material.icons.filled.BookmarkAdd
|
||||||
import androidx.compose.material.icons.filled.BookmarkRemove
|
import androidx.compose.material.icons.filled.BookmarkRemove
|
||||||
@ -109,6 +114,9 @@ internal fun BookView(
|
|||||||
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 readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
|
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
|
||||||
|
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
|
||||||
|
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
|
||||||
|
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
|
||||||
var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) }
|
var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) }
|
||||||
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
|
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
|
||||||
var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
|
var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
|
||||||
@ -147,6 +155,38 @@ internal fun BookView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
|
||||||
|
ReadingPosition(
|
||||||
|
listState.firstVisibleItemIndex,
|
||||||
|
listState.firstVisibleItemScrollOffset,
|
||||||
|
readAloudResumeSentenceIndex,
|
||||||
|
backStack,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openPosition(position: ReadingPosition, backStack: List<ReadingPosition>) {
|
||||||
|
scope.launch {
|
||||||
|
val target = position.copy(backStack = backStack)
|
||||||
|
readAloudResumeSentenceIndex = target.readAloudSentenceIndex
|
||||||
|
?: contentPlan.sentenceIndexAtOrAfterItem(target.itemIndex)
|
||||||
|
listState.animateScrollToItem(target.itemIndex, target.scrollOffset)
|
||||||
|
saveLibraryReadingPosition(fileId, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openTableOfContents() {
|
||||||
|
scope.launch {
|
||||||
|
val current = currentReadingPosition()
|
||||||
|
val savedStack = loadLibraryReadingPosition(fileId)?.backStack.orEmpty()
|
||||||
|
val nextStack = (listOf(current) + savedStack)
|
||||||
|
.withoutAdjacentReadingDuplicates()
|
||||||
|
.take(MaxTableOfContentsBackStack)
|
||||||
|
tableOfContentsBackStack = nextStack
|
||||||
|
tableOfContentsBackPosition = nextStack.backPositionFrom(current)
|
||||||
|
saveLibraryReadingPosition(fileId, current.copy(backStack = nextStack))
|
||||||
|
tableOfContentsVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
|
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (markLibraryReadingStatus(fileId, status)) {
|
if (markLibraryReadingStatus(fileId, status)) {
|
||||||
@ -157,7 +197,7 @@ internal fun BookView(
|
|||||||
if (status == BookReadingStatus.NOT_INTERESTED) {
|
if (status == BookReadingStatus.NOT_INTERESTED) {
|
||||||
saveLibraryReadingPosition(
|
saveLibraryReadingPosition(
|
||||||
fileId,
|
fileId,
|
||||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
currentReadingPosition(),
|
||||||
)
|
)
|
||||||
saveActiveReadingFileId(null)
|
saveActiveReadingFileId(null)
|
||||||
onBack()
|
onBack()
|
||||||
@ -192,6 +232,8 @@ internal fun BookView(
|
|||||||
|
|
||||||
LaunchedEffect(fileId) {
|
LaunchedEffect(fileId) {
|
||||||
loadLibraryReadingPosition(fileId)?.let { position ->
|
loadLibraryReadingPosition(fileId)?.let { position ->
|
||||||
|
tableOfContentsBackStack = position.backStack.take(MaxTableOfContentsBackStack)
|
||||||
|
tableOfContentsBackPosition = tableOfContentsBackStack.backPositionFrom(position)
|
||||||
readAloudResumeSentenceIndex = position.readAloudSentenceIndex
|
readAloudResumeSentenceIndex = position.readAloudSentenceIndex
|
||||||
listState.scrollToItem(position.itemIndex, position.scrollOffset)
|
listState.scrollToItem(position.itemIndex, position.scrollOffset)
|
||||||
}
|
}
|
||||||
@ -201,11 +243,7 @@ internal fun BookView(
|
|||||||
LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
|
LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
|
||||||
if (readAloudState.active) return@LaunchedEffect
|
if (readAloudState.active) return@LaunchedEffect
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
ReadingPosition(
|
currentReadingPosition()
|
||||||
listState.firstVisibleItemIndex,
|
|
||||||
listState.firstVisibleItemScrollOffset,
|
|
||||||
readAloudResumeSentenceIndex,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.filter { restored && userScrollGeneration > 0 }
|
.filter { restored && userScrollGeneration > 0 }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
@ -238,7 +276,7 @@ internal fun BookView(
|
|||||||
val sentence = activeReadAloudSentence ?: return@LaunchedEffect
|
val sentence = activeReadAloudSentence ?: return@LaunchedEffect
|
||||||
readAloudResumeSentenceIndex = sentence.index
|
readAloudResumeSentenceIndex = sentence.index
|
||||||
val itemIndex = sentence.itemIndex
|
val itemIndex = sentence.itemIndex
|
||||||
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index))
|
saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index, tableOfContentsBackStack))
|
||||||
if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) {
|
if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) {
|
||||||
listState.animateScrollToItem(itemIndex, 0)
|
listState.animateScrollToItem(itemIndex, 0)
|
||||||
}
|
}
|
||||||
@ -255,11 +293,12 @@ internal fun BookView(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
saveLibraryReadingPosition(
|
saveLibraryReadingPosition(
|
||||||
fileId,
|
fileId,
|
||||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
currentReadingPosition(),
|
||||||
)
|
)
|
||||||
onBookInfo()
|
onBookInfo()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onTableOfContents = ::openTableOfContents,
|
||||||
onMarkAsRead = {
|
onMarkAsRead = {
|
||||||
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
|
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
|
||||||
},
|
},
|
||||||
@ -302,11 +341,7 @@ internal fun BookView(
|
|||||||
},
|
},
|
||||||
showReadAloudAction = showReadAloudAction,
|
showReadAloudAction = showReadAloudAction,
|
||||||
onReadAloud = {
|
onReadAloud = {
|
||||||
val position = ReadingPosition(
|
val position = currentReadingPosition()
|
||||||
listState.firstVisibleItemIndex,
|
|
||||||
listState.firstVisibleItemScrollOffset,
|
|
||||||
readAloudResumeSentenceIndex,
|
|
||||||
)
|
|
||||||
val startIndex = contentPlan.resumeSentenceIndex(position)
|
val startIndex = contentPlan.resumeSentenceIndex(position)
|
||||||
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
|
||||||
readAloudPanelVisible = true
|
readAloudPanelVisible = true
|
||||||
@ -332,7 +367,7 @@ internal fun BookView(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
saveLibraryReadingPosition(
|
saveLibraryReadingPosition(
|
||||||
fileId,
|
fileId,
|
||||||
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
|
currentReadingPosition(),
|
||||||
)
|
)
|
||||||
saveActiveReadingFileId(null)
|
saveActiveReadingFileId(null)
|
||||||
onBack()
|
onBack()
|
||||||
@ -431,6 +466,35 @@ internal fun BookView(
|
|||||||
onNoteOpen = ::openReaderLink,
|
onNoteOpen = ::openReaderLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tableOfContentsVisible) {
|
||||||
|
val currentTocItemIndex = contentPlan.tocEntries
|
||||||
|
.lastOrNull { it.itemIndex <= listState.firstVisibleItemIndex }
|
||||||
|
?.itemIndex
|
||||||
|
TableOfContentsDialog(
|
||||||
|
contentPlan = contentPlan,
|
||||||
|
currentItemIndex = currentTocItemIndex,
|
||||||
|
backPosition = tableOfContentsBackPosition,
|
||||||
|
onOpenPosition = { position ->
|
||||||
|
tableOfContentsVisible = false
|
||||||
|
val targetIndex = tableOfContentsBackStack.indexOfFirst { it.sameReadingPlace(position) }
|
||||||
|
val remainingStack = if (targetIndex >= 0) {
|
||||||
|
tableOfContentsBackStack.drop(targetIndex + 1)
|
||||||
|
} else {
|
||||||
|
tableOfContentsBackStack
|
||||||
|
}
|
||||||
|
tableOfContentsBackStack = remainingStack
|
||||||
|
tableOfContentsBackPosition = remainingStack.backPositionFrom(position)
|
||||||
|
openPosition(position, remainingStack)
|
||||||
|
},
|
||||||
|
onOpenEntry = { entry ->
|
||||||
|
tableOfContentsVisible = false
|
||||||
|
val sentenceIndex = contentPlan.sentenceIndexAtOrAfterItem(entry.itemIndex)
|
||||||
|
openPosition(ReadingPosition(entry.itemIndex, 0, sentenceIndex), tableOfContentsBackStack)
|
||||||
|
},
|
||||||
|
onDismiss = { tableOfContentsVisible = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -438,6 +502,7 @@ private fun CompactReaderTopBar(
|
|||||||
title: String,
|
title: String,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
|
onTableOfContents: () -> Unit,
|
||||||
onMarkAsRead: () -> Unit,
|
onMarkAsRead: () -> Unit,
|
||||||
onMarkToRead: () -> Unit,
|
onMarkToRead: () -> Unit,
|
||||||
onNotInterested: () -> Unit,
|
onNotInterested: () -> Unit,
|
||||||
@ -475,6 +540,9 @@ private fun CompactReaderTopBar(
|
|||||||
IconButton(onClick = onThemeToggle) {
|
IconButton(onClick = onThemeToggle) {
|
||||||
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
|
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onTableOfContents) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = strings.tableOfContents)
|
||||||
|
}
|
||||||
if (showReadAloudAction) {
|
if (showReadAloudAction) {
|
||||||
IconButton(onClick = onReadAloud) {
|
IconButton(onClick = onReadAloud) {
|
||||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud)
|
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud)
|
||||||
@ -616,6 +684,131 @@ private fun CompactReaderTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TableOfContentsDialog(
|
||||||
|
contentPlan: ReaderContentPlan,
|
||||||
|
currentItemIndex: Int?,
|
||||||
|
backPosition: ReadingPosition?,
|
||||||
|
onOpenPosition: (ReadingPosition) -> Unit,
|
||||||
|
onOpenEntry: (ReaderTocEntry) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(strings.tableOfContents) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp),
|
||||||
|
) {
|
||||||
|
item(key = "back") {
|
||||||
|
TableOfContentsBackRow(
|
||||||
|
enabled = backPosition != null,
|
||||||
|
onClick = { backPosition?.let(onOpenPosition) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
items(contentPlan.tocEntries, key = { it.itemIndex }) { entry ->
|
||||||
|
TableOfContentsEntryRow(
|
||||||
|
entry = entry,
|
||||||
|
selected = entry.itemIndex == currentItemIndex,
|
||||||
|
onClick = { onOpenEntry(entry) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(strings.done)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TableOfContentsBackRow(enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
val contentColor = if (enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = strings.backToLastPosition,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = contentColor,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TableOfContentsEntryRow(
|
||||||
|
entry: ReaderTocEntry,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val rowModifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(
|
||||||
|
start = (12 + entry.depth * 16).dp,
|
||||||
|
top = 10.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
bottom = 10.dp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = rowModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ReadingPosition>.withoutAdjacentReadingDuplicates(): List<ReadingPosition> =
|
||||||
|
fold(emptyList()) { acc, position ->
|
||||||
|
if (acc.lastOrNull()?.sameReadingPlace(position) == true) acc else acc + position.withoutReadingBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReadingPosition.sameReadingPlace(other: ReadingPosition): Boolean =
|
||||||
|
itemIndex == other.itemIndex &&
|
||||||
|
scrollOffset == other.scrollOffset &&
|
||||||
|
readAloudSentenceIndex == other.readAloudSentenceIndex
|
||||||
|
|
||||||
|
private fun ReadingPosition.withoutReadingBackStack(): ReadingPosition =
|
||||||
|
copy(backStack = emptyList())
|
||||||
|
|
||||||
|
private fun List<ReadingPosition>.backPositionFrom(current: ReadingPosition): ReadingPosition? =
|
||||||
|
firstOrNull { !it.sameReadingPlace(current) }
|
||||||
|
|
||||||
|
private const val MaxTableOfContentsBackStack = 10
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReaderFontSettingsPanel(
|
private fun ReaderFontSettingsPanel(
|
||||||
settings: ReaderFontSettings,
|
settings: ReaderFontSettings,
|
||||||
|
|||||||
@ -305,6 +305,34 @@ class ReadAloudContentPlanTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentPlanIncludesClickableTableOfContentsEntries() {
|
||||||
|
val plan = buildReaderContentPlan(
|
||||||
|
Fb2Book(
|
||||||
|
title = "Book",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Part",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Chapter",
|
||||||
|
blocks = listOf(paragraph("Chapter text.")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Fb2Section(blocks = listOf(paragraph("Untitled text."))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("Part", "Chapter", "Section 2"), plan.tocEntries.map { it.title })
|
||||||
|
assertEquals(listOf(0, 0, 0), plan.tocEntries.map { it.depth })
|
||||||
|
assertEquals(
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().first(),
|
||||||
|
plan.elements[plan.tocEntries.first().itemIndex],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun paragraph(text: String): Fb2Block.Paragraph =
|
private fun paragraph(text: String): Fb2Block.Paragraph =
|
||||||
Fb2Block.Paragraph(text(text))
|
Fb2Block.Paragraph(text(text))
|
||||||
|
|
||||||
|
|||||||
@ -665,16 +665,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
|
|||||||
buildString {
|
buildString {
|
||||||
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
|
||||||
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
|
||||||
|
if (backStack.isNotEmpty()) {
|
||||||
|
append(""","backStack":[""")
|
||||||
|
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
|
||||||
|
append("]")
|
||||||
|
}
|
||||||
append("}")
|
append("}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toReadingPosition(): ReadingPosition? {
|
private fun String.toReadingPosition(): ReadingPosition? {
|
||||||
|
val position = toReadingPositionObject() ?: return null
|
||||||
|
val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1)
|
||||||
|
val backStack = backStackJson
|
||||||
|
?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() }
|
||||||
|
.orEmpty()
|
||||||
|
.take(MaxStoredReadingBackStack)
|
||||||
|
return position.copy(backStack = backStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toReadingPositionObject(): 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()
|
||||||
val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
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
|
return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""")
|
||||||
|
private const val MaxStoredReadingBackStack = 10
|
||||||
|
|
||||||
private fun String.toSearchPrefixes(): List<String> =
|
private fun String.toSearchPrefixes(): List<String> =
|
||||||
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user