contents for reading

This commit is contained in:
Sergey Chernov 2026-05-25 20:37:32 +03:00
parent cde4d89eed
commit 7681590992
7 changed files with 301 additions and 22 deletions

View File

@ -832,16 +832,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
if (backStack.isNotEmpty()) {
append(""","backStack":[""")
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
append("]")
}
append("}")
}
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 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()
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> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()

View File

@ -62,6 +62,7 @@ data class ReadingPosition(
val itemIndex: Int,
val scrollOffset: Int,
val readAloudSentenceIndex: Int? = null,
val backStack: List<ReadingPosition> = emptyList(),
)
data class ReaderFontSettings(

View File

@ -133,6 +133,8 @@ internal open class AppStrings {
open val noImage = "No image"
open val readerTheme = "Theme"
open val tableOfContents = "Contents"
open val backToLastPosition = "Back"
open val readAloud = "Read aloud"
open val readerMenu = "Book reader menu"
open val info = "Info..."
@ -337,6 +339,8 @@ internal object RussianStrings : AppStrings() {
override val noImage = "Нет изображения"
override val readerTheme = "Тема"
override val tableOfContents = "Содержание"
override val backToLastPosition = "Назад"
override val readAloud = "Читать вслух"
override val readerMenu = "Меню чтения"
override val info = "Информация..."

View File

@ -1463,6 +1463,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
lateinit var addSections: (List<Fb2Section>, Int) -> Unit
val tocEntries = mutableListOf<ReaderTocEntry>()
addPoemSentences = { itemIndex, poem ->
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
@ -1497,18 +1499,20 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
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()) {
elements += ReaderElement.SectionSeparator
} else {
val itemIndex = elements.size
elements += ReaderElement.SectionTitle(section.title!!, readerDepth)
elements += ReaderElement.SectionTitle(title, readerDepth)
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = 0,
endExclusive = section.title!!.length,
text = section.title!!,
endExclusive = title.length,
text = title,
pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis,
pauseAfterMillis = HeadingPauseAfterMillis,
)
@ -1548,7 +1552,13 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
}
}
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
@ -1558,15 +1568,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
addEpigraphSentences(itemIndex, epigraph)
elements += ReaderElement.Epigraph(epigraph, 0)
}
book.sections.forEach { addSection(it, 0) }
addSections(book.sections, 0)
elements += ReaderElement.FixedSpacer(22)
return ReaderContentPlan(elements, sentences)
return ReaderContentPlan(elements, sentences, tocEntries)
}
internal data class ReaderContentPlan(
val elements: List<ReaderElement>,
val sentences: List<ReadAloudSentence>,
val tocEntries: List<ReaderTocEntry>,
) {
fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int =
sentences.firstOrNull { it.itemIndex >= itemIndex }?.index
@ -1579,6 +1590,12 @@ internal data class ReaderContentPlan(
?: sentenceIndexAtOrAfterItem(position.itemIndex)
}
internal data class ReaderTocEntry(
val title: String,
val itemIndex: Int,
val depth: Int,
)
internal sealed interface ReaderElement {
data object Cover : ReaderElement
data class FixedSpacer(val heightDp: Int) : ReaderElement

View File

@ -1,21 +1,26 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
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.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
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.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
@ -109,6 +114,9 @@ internal fun BookView(
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible 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 readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
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) {
scope.launch {
if (markLibraryReadingStatus(fileId, status)) {
@ -157,7 +197,7 @@ internal fun BookView(
if (status == BookReadingStatus.NOT_INTERESTED) {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
currentReadingPosition(),
)
saveActiveReadingFileId(null)
onBack()
@ -192,6 +232,8 @@ internal fun BookView(
LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position ->
tableOfContentsBackStack = position.backStack.take(MaxTableOfContentsBackStack)
tableOfContentsBackPosition = tableOfContentsBackStack.backPositionFrom(position)
readAloudResumeSentenceIndex = position.readAloudSentenceIndex
listState.scrollToItem(position.itemIndex, position.scrollOffset)
}
@ -201,11 +243,7 @@ internal fun BookView(
LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) {
if (readAloudState.active) return@LaunchedEffect
snapshotFlow {
ReadingPosition(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
currentReadingPosition()
}
.filter { restored && userScrollGeneration > 0 }
.distinctUntilChanged()
@ -238,7 +276,7 @@ internal fun BookView(
val sentence = activeReadAloudSentence ?: return@LaunchedEffect
readAloudResumeSentenceIndex = sentence.index
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) {
listState.animateScrollToItem(itemIndex, 0)
}
@ -255,11 +293,12 @@ internal fun BookView(
scope.launch {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
currentReadingPosition(),
)
onBookInfo()
}
},
onTableOfContents = ::openTableOfContents,
onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
},
@ -302,11 +341,7 @@ internal fun BookView(
},
showReadAloudAction = showReadAloudAction,
onReadAloud = {
val position = ReadingPosition(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
readAloudResumeSentenceIndex,
)
val position = currentReadingPosition()
val startIndex = contentPlan.resumeSentenceIndex(position)
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
readAloudPanelVisible = true
@ -332,7 +367,7 @@ internal fun BookView(
scope.launch {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
currentReadingPosition(),
)
saveActiveReadingFileId(null)
onBack()
@ -431,6 +466,35 @@ internal fun BookView(
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
@ -438,6 +502,7 @@ private fun CompactReaderTopBar(
title: String,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onTableOfContents: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkToRead: () -> Unit,
onNotInterested: () -> Unit,
@ -475,6 +540,9 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme)
}
IconButton(onClick = onTableOfContents) {
Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = strings.tableOfContents)
}
if (showReadAloudAction) {
IconButton(onClick = onReadAloud) {
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
private fun ReaderFontSettingsPanel(
settings: ReaderFontSettings,

View File

@ -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 =
Fb2Block.Paragraph(text(text))

View File

@ -665,16 +665,34 @@ private fun ReadingPosition.toFormatHintsJson(): String =
buildString {
append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
if (backStack.isNotEmpty()) {
append(""","backStack":[""")
append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
append("]")
}
append("}")
}
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 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()
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> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()