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

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayContract.kt

@@ -19,6 +19,7 @@ data class AudioPlayUiState(
     val chapterIndex: Int = 0,
     val chapterTitle: String = "",
     val chapters: ImmutableList<PlayerChapterUi> = persistentListOf(),
+    val lyricLines: ImmutableList<AudioLyricLine> = persistentListOf(),
     val status: Int = Status.STOP,
     val isLoading: Boolean = false,
     val position: Int = 0,
@@ -41,6 +42,12 @@ data class AudioPlayUiState(
     val isPlaying: Boolean get() = status == Status.PLAY
 }
 
+@Stable
+data class AudioLyricLine(
+    val timestampMs: Int,
+    val text: String,
+)
+
 /** 播放器内的设置弹窗(由 UiState 管理,避免分散在 Screen 本地状态)。 */
 sealed interface AudioPlaySheet {
     data object SkipCredits : AudioPlaySheet
@@ -88,4 +95,4 @@ sealed interface AudioPlayEffect {
     data object CopyPlayUrl : AudioPlayEffect
     data class OpenEditSource(val sourceUrl: String) : AudioPlayEffect
     data class OpenBookReader(val bookUrl: String) : AudioPlayEffect
-}
+}

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayCoordinator.kt

@@ -98,6 +98,7 @@ class AudioPlayCoordinator(
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
             chapters = chapterList,
+            lyric = book.lyric,
             status = book.status,
             isLoading = isLoading,
             position = book.position,
@@ -127,6 +128,7 @@ class AudioPlayCoordinator(
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
             chapters = persistentListOf(),
+            lyric = book.lyric,
             status = book.status,
             isLoading = loading.value,
             position = book.position,
@@ -219,6 +221,8 @@ class AudioPlayCoordinator(
             sourceOrigin = book?.origin,
             chapterIndex = AudioPlay.durChapterIndex,
             chapterTitle = chapter?.title.orEmpty(),
+            lyric = chapter?.getVariable("lyric").takeIf { !it.isNullOrBlank() }
+                ?: AudioPlay.durLyric,
             status = AudioPlay.status,
             position = AudioPlay.durChapterPos,
             duration = AudioPlay.durAudioSize,
@@ -253,6 +257,7 @@ data class AudioPlaySourceState(
     val chapterIndex: Int,
     val chapterTitle: String,
     val chapters: ImmutableList<PlayerChapterUi>,
+    val lyric: String?,
     val status: Int,
     val isLoading: Boolean,
     val position: Int,
@@ -278,6 +283,7 @@ private data class AudioPlayBookState(
     val sourceOrigin: String?,
     val chapterIndex: Int,
     val chapterTitle: String,
+    val lyric: String?,
     val status: Int,
     val position: Int,
     val duration: Int,

+ 119 - 11
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -2,7 +2,9 @@ package io.legado.app.ui.book.audio
 
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -18,6 +20,10 @@ import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+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.CircleShape
@@ -49,12 +55,14 @@ import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -100,9 +108,11 @@ import io.legado.app.ui.widget.components.player.PlayerProgressSlider
 import io.legado.app.ui.widget.components.player.PlayerTocPage
 import io.legado.app.ui.widget.components.player.playerBgModeLabel
 import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import top.yukonga.miuix.kmp.blur.layerBackdrop
 import java.util.Locale
+import kotlin.math.abs
 import kotlin.math.roundToInt
 
 @OptIn(ExperimentalHazeMaterialsApi::class)
@@ -112,6 +122,10 @@ fun AudioPlayScreenContent(
     onIntent: (AudioPlayIntent) -> Unit,
     onBack: () -> Unit,
 ) {
+    val horizontalPagerState = rememberPagerState(
+        initialPage = 0,
+        pageCount = { if (state.lyricLines.isEmpty()) 1 else 2 },
+    )
     val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
     val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
         state = verticalPagerState,
@@ -158,7 +172,7 @@ fun AudioPlayScreenContent(
                 Row(
                     modifier = Modifier
                         .fillMaxWidth()
-                        .padding(horizontal = 16.dp, vertical = 8.dp),
+                        .padding(horizontal = 8.dp, vertical = 8.dp),
                     verticalAlignment = Alignment.CenterVertically,
                     horizontalArrangement = Arrangement.SpaceBetween
                 ) {
@@ -390,6 +404,15 @@ fun AudioPlayScreenContent(
                         ),
                         onCheckedChange = {
                             coroutineScope.launch {
+                                if (horizontalPagerState.currentPage != 0) {
+                                    horizontalPagerState.animateScrollToPage(
+                                        page = 0,
+                                        animationSpec = tween(
+                                            durationMillis = 520,
+                                            easing = FastOutSlowInEasing,
+                                        ),
+                                    )
+                                }
                                 verticalPagerState.animateScrollToPage(
                                     page = if (verticalPagerState.currentPage == 0) 1 else 0,
                                     animationSpec = tween(
@@ -457,21 +480,35 @@ fun AudioPlayScreenContent(
                     Modifier
                 },
             )
-            VerticalPager(
-                state = verticalPagerState,
+            HorizontalPager(
+                state = horizontalPagerState,
                 modifier = Modifier.fillMaxSize(),
-                horizontalAlignment = Alignment.CenterHorizontally,
-                pageNestedScrollConnection = verticalPagerNestedScrollConnection,
+                verticalAlignment = Alignment.CenterVertically,
             ) { page ->
                 if (page == 0) {
-                    AudioCoverPage(state, pageContentPadding)
+                    VerticalPager(
+                        state = verticalPagerState,
+                        modifier = Modifier.fillMaxSize(),
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                        pageNestedScrollConnection = verticalPagerNestedScrollConnection,
+                    ) { verticalPage ->
+                        if (verticalPage == 0) {
+                            AudioCoverPage(state, pageContentPadding)
+                        } else {
+                            PlayerTocPage(
+                                chapters = state.chapters,
+                                currentIndex = state.chapterIndex,
+                                isPaused = !state.isPlaying,
+                                onSelect = { onIntent(AudioPlayIntent.SelectChapter(it)) },
+                                contentPadding = pageContentPadding,
+                            )
+                        }
+                    }
                 } else {
-                    PlayerTocPage(
-                        chapters = state.chapters,
-                        currentIndex = state.chapterIndex,
-                        isPaused = !state.isPlaying,
-                        onSelect = { onIntent(AudioPlayIntent.SelectChapter(it)) },
+                    AudioLyricPage(
+                        state = state,
                         contentPadding = pageContentPadding,
+                        onIntent = onIntent,
                     )
                 }
             }
@@ -517,6 +554,77 @@ private const val AUDIO_GAIN_MIN = -6000
 private const val AUDIO_GAIN_MAX = 6000
 private const val CREDITS_MAX_SECONDS = 180
 
+@Composable
+private fun AudioLyricPage(
+    state: AudioPlayUiState,
+    contentPadding: PaddingValues,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    val listState = rememberLazyListState()
+    val activeLine = state.lyricLines.indexOfLast { it.timestampMs <= state.position }
+
+    LaunchedEffect(state.lyricLines, activeLine) {
+        if (activeLine !in state.lyricLines.indices) return@LaunchedEffect
+
+        snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 }
+        val layoutInfo = listState.layoutInfo
+        val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
+        val targetDistance = viewportHeight * 0.32f
+        val targetOffset = layoutInfo.viewportStartOffset + targetDistance
+        val visibleItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == activeLine }
+        val scrollDistance = if (visibleItem != null) {
+            visibleItem.offset - targetOffset
+        } else {
+            val approachDistance = (viewportHeight * 0.08f).coerceAtLeast(1f)
+            listState.scrollToItem(
+                index = activeLine,
+                scrollOffset = -(targetDistance + approachDistance).roundToInt(),
+            )
+            approachDistance
+        }
+        if (abs(scrollDistance) > 1f) {
+            listState.animateScrollBy(
+                value = scrollDistance,
+                animationSpec = tween(durationMillis = 520, easing = FastOutSlowInEasing),
+            )
+        }
+    }
+
+    LazyColumn(
+        state = listState,
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(horizontal = 16.dp),
+        contentPadding = PaddingValues(
+            top = contentPadding.calculateTopPadding(),
+            bottom = contentPadding.calculateBottomPadding(),
+        ),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        itemsIndexed(
+            items = state.lyricLines,
+            key = { index, line -> "${line.timestampMs}:$index" },
+            contentType = { _, _ -> "audio_lyric_line" },
+        ) { index, line ->
+            val active = index == activeLine
+            AppText(
+                text = line.text,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clip(RoundedCornerShape(16.dp))
+                    .clickable { onIntent(AudioPlayIntent.SeekTo(line.timestampMs)) }
+                    .padding(horizontal = 12.dp, vertical = 12.dp),
+                style = LegadoTheme.typography.titleLargeEmphasized,
+                color = if (active) {
+                    LegadoTheme.colorScheme.onSurface
+                } else {
+                    LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+                },
+            )
+        }
+    }
+}
+
 @Composable
 private fun AudioSkipCreditsSheet(
     show: Boolean,

+ 32 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt

@@ -27,6 +27,8 @@ import io.legado.app.help.config.compatDsInt
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.postEvent
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -113,6 +115,11 @@ class AudioPlayViewModel(
         coordinator.setLoading(loading)
     }
 
+    /** [AudioPlay] 的歌词回调不携带 UI 状态,重新快照即可读取最新歌词。 */
+    fun onLyricChanged() {
+        coordinator.refresh()
+    }
+
     fun onIntent(intent: AudioPlayIntent) {
         when (intent) {
             is AudioPlayIntent.Init -> init(intent.bookUrl, intent.inBookshelf)
@@ -297,6 +304,7 @@ class AudioPlayViewModel(
         chapterIndex = source.chapterIndex,
         chapterTitle = source.chapterTitle,
         chapters = source.chapters,
+        lyricLines = parseAudioLyrics(source.lyric),
         status = source.status,
         isLoading = source.isLoading,
         position = source.position,
@@ -321,3 +329,27 @@ class AudioPlayViewModel(
         _effects.tryEmit(value)
     }
 }
+
+private val lyricTimestampPattern = Regex("\\[(\\d{1,3}):(\\d{2})(?:[.:](\\d{1,3}))?]")
+
+private fun parseAudioLyrics(lyric: String?): kotlinx.collections.immutable.ImmutableList<AudioLyricLine> {
+    if (lyric.isNullOrBlank()) return persistentListOf()
+    return lyric.lineSequence().flatMap { line ->
+        val matches = lyricTimestampPattern.findAll(line).toList()
+        val text = line.substring(matches.lastOrNull()?.range?.last?.plus(1) ?: 0).trim()
+        matches.asSequence().mapNotNull { match ->
+            text.takeIf { it.isNotEmpty() }?.let {
+                val minutes = match.groupValues[1].toIntOrNull() ?: return@let null
+                val seconds = match.groupValues[2].toIntOrNull() ?: return@let null
+                val fraction = match.groupValues[3]
+                val millis = when (fraction.length) {
+                    0 -> 0
+                    1 -> fraction.toInt() * 100
+                    2 -> fraction.toInt() * 10
+                    else -> fraction.take(3).toInt()
+                }
+                AudioLyricLine((minutes * 60 + seconds) * 1_000 + millis, it)
+            }
+        }
+    }.sortedBy(AudioLyricLine::timestampMs).toImmutableList()
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -674,7 +674,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
     }
 
     override fun upLyric(lyric: String?) {
-        // 歌词暂不在界面展示
+        activeAudioPlayViewModel?.onLyricChanged()
     }
 
     override fun upLyricP(position: Int) {