Просмотр исходного кода

feat: 优化了听书相关功能

HapeLee 3 месяцев назад
Родитель
Сommit
d52a561a88

+ 1 - 1
app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt

@@ -11,7 +11,7 @@ fun TextChapter.toCanonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> {
         .mapIndexed { fallbackIndex, paragraph ->
             CanonicalSpeechParagraph(
                 index = (paragraph.realNum - 1).takeIf { it >= 0 } ?: fallbackIndex,
-                text = paragraph.text,
+                text = paragraph.text.replace(Regex("[袮祢꧁]"), " "),
                 chapterPosition = paragraph.chapterPosition,
             )
         }

+ 0 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -440,7 +440,6 @@ fun ReadBookScreen(
             )
             aloudPlayerViewModel.effects.collectLatest { effect ->
                 when (effect) {
-                    ReadAloudPlayerEffect.OpenToc -> onIntent(ReadBookIntent.OpenChapterList)
                     ReadAloudPlayerEffect.ReturnToReaderSettings ->
                         onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
                     ReadAloudPlayerEffect.ReturnToClassic ->

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -8,9 +8,9 @@ import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.help.book.BookContent
+import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextChapterLayout
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.utils.fastBinarySearchBy
 import kotlinx.coroutines.CoroutineScope
 import kotlin.math.abs
@@ -203,7 +203,7 @@ data class TextChapter(
         val stringBuilder = StringBuilder()
         if (pages.isNotEmpty()) {
             for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) {
-                stringBuilder.append(pages[index].text.replace(Regex("[袮꧁]"), " "))
+                stringBuilder.append(pages[index].text.replace(Regex("[袮祢꧁]"), " "))
                 if (pageSplit && !stringBuilder.endsWith("\n")) {
                     stringBuilder.append("\n")
                 }

+ 11 - 2
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -10,6 +10,14 @@ data class ReadAloudTextLineUi(
     val chapterPosition: Int,
 )
 
+@Stable
+data class ReadAloudChapterUi(
+    val index: Int,
+    val title: String,
+    val isVolume: Boolean,
+    val tocLevel: Int,
+)
+
 @Stable
 data class ReadAloudPlayerUiState(
     val bookUrl: String = "",
@@ -19,6 +27,7 @@ data class ReadAloudPlayerUiState(
     val sourceOrigin: String? = null,
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
+    val chapters: ImmutableList<ReadAloudChapterUi> = persistentListOf(),
     val chapterText: String = "",
     val textLines: ImmutableList<ReadAloudTextLineUi> = persistentListOf(),
     val activeTextLine: Int = -1,
@@ -43,7 +52,8 @@ sealed interface ReadAloudPlayerIntent {
     data object NextParagraph : ReadAloudPlayerIntent
     data object OpenSettings : ReadAloudPlayerIntent
     data object SwitchToClassic : ReadAloudPlayerIntent
-    data object OpenToc : ReadAloudPlayerIntent
+    data object CycleBgMode : ReadAloudPlayerIntent
+    data class SelectChapter(val index: Int) : ReadAloudPlayerIntent
     data class SetBgMode(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
@@ -51,7 +61,6 @@ sealed interface ReadAloudPlayerIntent {
 }
 
 sealed interface ReadAloudPlayerEffect {
-    data object OpenToc : ReadAloudPlayerEffect
     data object ReturnToReaderSettings : ReadAloudPlayerEffect
     data object ReturnToClassic : ReadAloudPlayerEffect
 }

+ 39 - 3
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt

@@ -4,6 +4,7 @@ import android.app.Application
 import androidx.lifecycle.Observer
 import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.constant.EventBus
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.model.ReadAloud
@@ -13,12 +14,16 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
 
@@ -27,6 +32,7 @@ class ReadAloudPlayerCoordinator(
     private val application: Application,
     private val sessionStore: ReadAloudSessionStore,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val bookRepository: BookRepository,
 ) {
     private val refreshRequests = MutableSharedFlow<Unit>(replay = 1)
     private val bookChanges = callbackFlow {
@@ -41,10 +47,29 @@ class ReadAloudPlayerCoordinator(
     private val bookState =
         merge(bookChanges, refreshRequests, configChanges).map { snapshotBook() }
 
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val bookWithChapters = bookState.flatMapLatest { book ->
+        if (book.bookUrl.isBlank()) {
+            flowOf(book to persistentListOf<ReadAloudChapterSourceState>())
+        } else {
+            bookRepository.flowChapters(book.bookUrl).map { chapters ->
+                val sourceChapters = chapters.map { chapter ->
+                    ReadAloudChapterSourceState(
+                        index = chapter.index,
+                        title = chapter.title,
+                        isVolume = chapter.isVolume,
+                        tocLevel = chapter.tocLevel,
+                    )
+                }.toImmutableList()
+                book to sourceChapters
+            }
+        }
+    }
+
     val state: Flow<ReadAloudPlayerSourceState> = combine(
         sessionStore.state,
-        bookState,
-    ) { session, book ->
+        bookWithChapters,
+    ) { session, (book, chapters) ->
         val playback = session.playback
         ReadAloudPlayerSourceState(
             bookUrl = book.bookUrl,
@@ -54,6 +79,7 @@ class ReadAloudPlayerCoordinator(
             sourceOrigin = book.sourceOrigin,
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
+            chapters = chapters,
             chapterText = book.chapterText,
             textLines = book.textLines,
             chapterPosition = playback.chapterPosition,
@@ -79,6 +105,7 @@ class ReadAloudPlayerCoordinator(
             sourceOrigin = book.sourceOrigin,
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
+            chapters = persistentListOf(),
             chapterText = book.chapterText,
             textLines = book.textLines,
             chapterPosition = playback.chapterPosition,
@@ -109,7 +136,7 @@ class ReadAloudPlayerCoordinator(
             chapterTitle = chapter?.title.orEmpty(),
             chapterText = chapter?.getContent().orEmpty(),
             textLines = chapter?.paragraphs.orEmpty().mapNotNull { paragraph ->
-                paragraph.text.replace(Regex("[袮꧁]"), " ").trim()
+                paragraph.text.replace(Regex("[袮祢꧁]"), " ").trim()
                     .takeIf(String::isNotEmpty)?.let {
                     ReadAloudTextLineUi(it, paragraph.chapterPosition)
                 }
@@ -129,6 +156,7 @@ class ReadAloudPlayerCoordinator(
     fun nextParagraph() = ReadAloud.nextParagraph(application)
     fun previousChapter() = ReadBook.moveToPrevChapter(true, false)
     fun nextChapter() = ReadBook.moveToNextChapter(true)
+    fun selectChapter(index: Int) = ReadBook.openChapter(index, durChapterPos = 0)
 
     suspend fun setSpeed(value: Int) {
         readAloudSettingsGateway.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }
@@ -168,6 +196,13 @@ class ReadAloudPlayerCoordinator(
     )
 }
 
+data class ReadAloudChapterSourceState(
+    val index: Int,
+    val title: String,
+    val isVolume: Boolean,
+    val tocLevel: Int,
+)
+
 data class ReadAloudPlayerSourceState(
     val bookUrl: String,
     val bookName: String,
@@ -176,6 +211,7 @@ data class ReadAloudPlayerSourceState(
     val sourceOrigin: String?,
     val chapterIndex: Int,
     val chapterTitle: String,
+    val chapters: ImmutableList<ReadAloudChapterSourceState>,
     val chapterText: String,
     val textLines: ImmutableList<ReadAloudTextLineUi>,
     val chapterPosition: Int,

+ 281 - 23
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -14,6 +14,7 @@ import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
 import androidx.compose.foundation.layout.Arrangement
@@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.aspectRatio
@@ -33,15 +35,18 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Settings
@@ -50,6 +55,7 @@ import androidx.compose.material.icons.filled.SkipPrevious
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.material3.Slider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
@@ -99,11 +105,14 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.effect.BgEffectBackground
 import io.legado.app.ui.widget.components.effect.BgEffectConfig
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
+import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
@@ -116,6 +125,7 @@ import top.yukonga.miuix.kmp.blur.layerBackdrop
 import top.yukonga.miuix.kmp.blur.textureBlur
 import kotlin.math.abs
 import kotlin.math.roundToInt
+import kotlin.time.Duration.Companion.milliseconds
 import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode
 
 @OptIn(ExperimentalHazeMaterialsApi::class)
@@ -125,7 +135,13 @@ fun ReadAloudPlayerScreenContent(
     onIntent: (ReadAloudPlayerIntent) -> Unit,
     onBack: () -> Unit,
 ) {
-    val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val horizontalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
+        state = verticalPagerState,
+        orientation = Orientation.Vertical,
+    )
+    val coroutineScope = rememberCoroutineScope()
     var activeAdjustment by remember { mutableStateOf<PlayerAdjustment?>(null) }
     var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed.toFloat()) }
     var timerPreview by remember(state.timerMinutes) {
@@ -148,8 +164,8 @@ fun ReadAloudPlayerScreenContent(
     } else {
         Modifier
     }
-    LaunchedEffect(pagerState.currentPage) {
-        if (pagerState.currentPage != 1) isTextPageUserScrolling = false
+    LaunchedEffect(horizontalPagerState.currentPage) {
+        if (horizontalPagerState.currentPage != 1) isTextPageUserScrolling = false
     }
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
@@ -259,8 +275,16 @@ fun ReadAloudPlayerScreenContent(
                             .padding(horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceBetween
                     ) {
-                        AppText(formatPosition(state.chapterPosition), style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant)
-                        AppText("${(state.chapterPosition * 100 / state.chapterLength.coerceAtLeast(1))}%", style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant)
+                        AppText(
+                            formatPosition(state.chapterPosition),
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                        AppText(
+                            "${(state.chapterPosition * 100 / state.chapterLength.coerceAtLeast(1))}%",
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
                     }
 
                     Row(
@@ -308,9 +332,29 @@ fun ReadAloudPlayerScreenContent(
                         horizontalArrangement = Arrangement.SpaceEvenly,
                     ) {
                         SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.OpenToc) },
-                            icon = Icons.AutoMirrored.Filled.MenuBook,
-                            contentDescription = stringResource(R.string.chapter_list),
+                            onClick = {
+                                coroutineScope.launch {
+                                    verticalPagerState.animateScrollToPage(
+                                        page = if (verticalPagerState.currentPage == 0) 1 else 0,
+                                        animationSpec = tween(
+                                            durationMillis = 600,
+                                            easing = FastOutSlowInEasing,
+                                        ),
+                                    )
+                                }
+                            },
+                            icon = if (verticalPagerState.currentPage == 0) {
+                                Icons.AutoMirrored.Filled.FormatListBulleted
+                            } else {
+                                Icons.Default.KeyboardArrowUp
+                            },
+                            contentDescription = stringResource(
+                                if (verticalPagerState.currentPage == 0) {
+                                    R.string.chapter_list
+                                } else {
+                                    R.string.back
+                                }
+                            ),
                         )
                         SmallPlainButton(
                             onClick = { onIntent(ReadAloudPlayerIntent.SwitchToClassic) },
@@ -318,9 +362,9 @@ fun ReadAloudPlayerScreenContent(
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
                         )
                         SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.OpenSettings) },
-                            icon = Icons.Default.Settings,
-                            contentDescription = stringResource(R.string.setting),
+                            onClick = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
+                            icon = Icons.Default.WbTwilight,
+                            contentDescription = bgModeLabel(state.bgMode)
                         )
                         SmallPlainButton(
                             onClick = {
@@ -340,31 +384,43 @@ fun ReadAloudPlayerScreenContent(
                         )
                     }
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Speed) {
-                        Slider(
+                        PlayerAdjustmentSlider(
+                            title = stringResource(R.string.read_aloud_adjust_speed),
                             value = speedPreview.coerceIn(5f, 20f),
+                            valueLabel = (speedPreview / 10f).let {
+                                if (it == it.roundToInt().toFloat()) {
+                                    "${it.roundToInt()}.0"
+                                } else {
+                                    String.format("%.1f", it)
+                                }
+                            },
+                            startLabel = stringResource(R.string.fast_rewind),
+                            endLabel = stringResource(R.string.fast_forward),
                             onValueChange = { speedPreview = it },
                             onValueChangeFinished = {
                                 onIntent(ReadAloudPlayerIntent.SetSpeed(speedPreview.roundToInt()))
                             },
                             valueRange = 5f..20f,
                             steps = 14,
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(horizontal = 16.dp),
                         )
                     }
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Timer) {
-                        Slider(
+                        PlayerAdjustmentSlider(
+                            title = stringResource(R.string.set_timer),
                             value = timerPreview.coerceIn(0f, 180f),
+                            valueLabel = if (timerPreview == 0f) {
+                                stringResource(R.string.close)
+                            } else {
+                                stringResource(R.string.timer_m, timerPreview.roundToInt())
+                            },
+                            startLabel = stringResource(R.string.close),
+                            endLabel = stringResource(R.string.timer_m, 180),
                             onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
                             onValueChangeFinished = {
                                 onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
                             },
                             valueRange = 0f..180f,
                             steps = 17,
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(horizontal = 16.dp),
                         )
                     }
                 }
@@ -384,12 +440,27 @@ fun ReadAloudPlayerScreenContent(
                 },
             )
             HorizontalPager(
-                state = pagerState,
+                state = horizontalPagerState,
                 modifier = Modifier.fillMaxSize(),
                 verticalAlignment = Alignment.CenterVertically,
             ) { page ->
                 if (page == 0) {
-                    CoverPage(state, pageContentPadding, flowingTextModifier)
+                    VerticalPager(
+                        state = verticalPagerState,
+                        modifier = Modifier.fillMaxSize(),
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                        pageNestedScrollConnection = verticalPagerNestedScrollConnection,
+                    ) { verticalPage ->
+                        if (verticalPage == 0) {
+                            CoverPage(state, pageContentPadding, flowingTextModifier)
+                        } else {
+                            ChapterTocPage(
+                                state = state,
+                                contentPadding = pageContentPadding,
+                                onIntent = onIntent,
+                            )
+                        }
+                    }
                 } else {
                     ChapterTextPage(
                         state = state,
@@ -403,6 +474,184 @@ fun ReadAloudPlayerScreenContent(
         }
     }
 }
+
+@Composable
+private fun PlayerAdjustmentSlider(
+    title: String,
+    value: Float,
+    valueLabel: String,
+    startLabel: String,
+    endLabel: String,
+    onValueChange: (Float) -> Unit,
+    onValueChangeFinished: () -> Unit,
+    valueRange: ClosedFloatingPointRange<Float>,
+    steps: Int,
+) {
+    NormalCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        cornerRadius = 28.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
+        ) {
+            AppText(
+                text = title,
+                style = LegadoTheme.typography.titleMediumEmphasized,
+            )
+            Spacer(Modifier.height(8.dp))
+            Slider(
+                value = value,
+                onValueChange = onValueChange,
+                onValueChangeFinished = onValueChangeFinished,
+                valueRange = valueRange,
+                steps = steps,
+                thumb = {
+                    Box(
+                        modifier = Modifier
+                            .size(58.dp)
+                            .clip(RoundedCornerShape(20.dp))
+                            .background(LegadoTheme.colorScheme.primaryContainer),
+                        contentAlignment = Alignment.Center,
+                    ) {
+                        AppText(
+                            text = valueLabel,
+                            style = LegadoTheme.typography.titleMediumEmphasized,
+                            color = LegadoTheme.colorScheme.onPrimaryContainer,
+                            maxLines = 1,
+                        )
+                    }
+                },
+                modifier = Modifier.fillMaxWidth(),
+            )
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 4.dp),
+                horizontalArrangement = Arrangement.SpaceBetween,
+            ) {
+                AppText(
+                    text = startLabel,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                AppText(
+                    text = endLabel,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ChapterTocPage(
+    state: ReadAloudPlayerUiState,
+    contentPadding: PaddingValues,
+    onIntent: (ReadAloudPlayerIntent) -> Unit,
+) {
+    val listState = rememberLazyListState()
+    LaunchedEffect(state.chapterIndex, state.chapters) {
+        val currentItem = state.chapters.indexOfFirst {
+            !it.isVolume && it.index == state.chapterIndex
+        }
+        if (currentItem >= 0) {
+            listState.scrollToItem((currentItem - 2).coerceAtLeast(0))
+        }
+    }
+
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(
+            start = 16.dp,
+            top = contentPadding.calculateTopPadding() + 12.dp,
+            end = 16.dp,
+            bottom = contentPadding.calculateBottomPadding() + 12.dp,
+        ),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        items(
+            items = state.chapters,
+            key = { chapter ->
+                if (chapter.isVolume) "player-volume-${chapter.index}"
+                else "player-chapter-${chapter.index}"
+            },
+            contentType = { chapter -> if (chapter.isVolume) "volume" else "chapter" },
+        ) { chapter ->
+            if (chapter.isVolume) {
+                AppText(
+                    text = chapter.title,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(
+                            start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp,
+                            top = 12.dp,
+                            bottom = 4.dp,
+                        ),
+                    style = LegadoTheme.typography.titleSmallEmphasized,
+                    color = LegadoTheme.colorScheme.primary,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            } else {
+                val isCurrent = chapter.index == state.chapterIndex
+                NormalCard(
+                    onClick = { onIntent(ReadAloudPlayerIntent.SelectChapter(chapter.index)) },
+                    cornerRadius = 12.dp,
+                    containerColor = if (isCurrent) {
+                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+                    } else {
+                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0f)
+                    },
+                    contentColor = if (isCurrent) {
+                        LegadoTheme.colorScheme.onSurface
+                    } else {
+                        LegadoTheme.colorScheme.onSurface
+                    },
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp),
+                ) {
+                    Row(
+                        modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        if (isCurrent) {
+                            AppIcon(
+                                imageVector = if (state.isPaused) {
+                                    Icons.Default.PlayArrow
+                                } else {
+                                    Icons.Default.Pause
+                                },
+                                contentDescription = null,
+                                modifier = Modifier
+                                    .padding(end = 8.dp)
+                                    .size(16.dp),
+                            )
+                        }
+                        Column(modifier = Modifier.weight(1f)) {
+                            AppText(
+                                text = chapter.title,
+                                style = if (isCurrent) {
+                                    LegadoTheme.typography.bodyMediumEmphasized
+                                } else {
+                                    LegadoTheme.typography.bodyMedium
+                                },
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
 @Composable
 private fun PlayerProgressSlider(
     value: Float,
@@ -615,7 +864,7 @@ private fun ChapterTextPage(
             override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                 val releaseGeneration = userScrollGeneration.intValue
                 coroutineScope.launch {
-                    delay(BOTTOM_BAR_RESTORE_DELAY_MILLIS)
+                    delay(BOTTOM_BAR_RESTORE_DELAY_MILLIS.milliseconds)
                     if (userScrollGeneration.intValue == releaseGeneration) {
                         currentOnUserScrollChanged(false)
                     }
@@ -879,3 +1128,12 @@ private fun flowingTextBlend(): List<BlendColorEntry> {
         }
     }
 }
+
+@Composable
+private fun bgModeLabel(mode: Int): String = when (mode) {
+    ReadAloudBgMode.Solid -> stringResource(R.string.read_aloud_bg_solid)
+    ReadAloudBgMode.Blur -> stringResource(R.string.read_aloud_bg_blur)
+    ReadAloudBgMode.FlowingLight -> stringResource(R.string.read_aloud_bg_flowing_light)
+    ReadAloudBgMode.Transparent -> stringResource(R.string.read_aloud_bg_transparent)
+    else -> stringResource(R.string.read_aloud_bg_blur)
+}

+ 23 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -6,6 +6,7 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsInt
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
@@ -41,7 +42,8 @@ class ReadAloudPlayerViewModel(
             ReadAloudPlayerIntent.NextChapter -> coordinator.nextChapter()
             ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
-            ReadAloudPlayerIntent.OpenToc -> effect(ReadAloudPlayerEffect.OpenToc)
+            ReadAloudPlayerIntent.CycleBgMode -> cycleBgMode()
+            is ReadAloudPlayerIntent.SelectChapter -> coordinator.selectChapter(intent.index)
             is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
                 PreferKey.readAloudPlayerBgMode,
                 intent.value,
@@ -57,6 +59,16 @@ class ReadAloudPlayerViewModel(
         }
     }
 
+    private fun cycleBgMode() {
+        val next = when (readBgMode()) {
+            ReadAloudBgMode.Solid -> ReadAloudBgMode.Blur
+            ReadAloudBgMode.Blur -> ReadAloudBgMode.FlowingLight
+            ReadAloudBgMode.FlowingLight -> ReadAloudBgMode.Transparent
+            else -> ReadAloudBgMode.Blur
+        }
+        AppConfigStore.putInt(PreferKey.readAloudPlayerBgMode, next)
+    }
+
     private fun toUiState(
         source: ReadAloudPlayerSourceState,
         bgMode: Int,
@@ -64,6 +76,14 @@ class ReadAloudPlayerViewModel(
         val activeIndex = source.textLines.indexOfLast {
             it.chapterPosition <= source.chapterPosition
         }
+        val chapters = source.chapters.map { chapter ->
+            ReadAloudChapterUi(
+                index = chapter.index,
+                title = chapter.title,
+                isVolume = chapter.isVolume,
+                tocLevel = chapter.tocLevel,
+            )
+        }.toImmutableList()
         return ReadAloudPlayerUiState(
             bookUrl = source.bookUrl,
             bookName = source.bookName,
@@ -72,6 +92,7 @@ class ReadAloudPlayerViewModel(
             sourceOrigin = source.sourceOrigin,
             chapterIndex = source.chapterIndex,
             chapterTitle = source.chapterTitle,
+            chapters = chapters,
             chapterText = source.chapterText,
             textLines = source.textLines,
             activeTextLine = activeIndex,
@@ -96,4 +117,5 @@ class ReadAloudPlayerViewModel(
         return AppConfigStore.preferences.compatDsInt(PreferKey.readAloudPlayerBgMode)
             ?: ReadAloudBgMode.Blur
     }
+
 }