fix lyric show and music trans logic

This commit is contained in:
2026-09-19 12:40:26 +08:00
parent ed37d0d570
commit 1fe55a2c84
11 changed files with 707 additions and 171 deletions
@@ -27,6 +27,7 @@ import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.lyric.LyricService
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.lyric.translationAt
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.network.createHttpClient
@@ -616,7 +617,7 @@ object AppContainer {
val lrcType = lyricService.lrcType
if (lrcType > 0) {
var extText = lyric.translation.getOrNull(curIndex)?.text?.replaceLine()
var extText = lyric.translationAt(curIndex)?.text?.replaceLine()
if (mainText.isEmpty()) extText = ""
postDesktopLyric(mainText, extText, 0)
return
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@@ -27,7 +28,11 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Music
import kotlin.math.absoluteValue
/** 中心封面尺寸;侧页在此基础上缩放(较 230 放大 15%)。 */
private val CoverSize = 264.5.dp
internal val CoverCarouselSize = 264.5.dp
internal const val CoverCarouselPageFraction = 0.56f
internal const val CoverCarouselSideScale = 0.5f
internal const val CoverCarouselSideAlpha = 0.3f
/**
* 驾驶模式封面轮播:
@@ -75,26 +80,43 @@ fun DriveModeCarousel(
}
}
CoverCarouselPager(
state = pagerState,
modifier = modifier,
) { page ->
DriveModeCoverCircle(music = queue[page])
}
}
/** 驾驶模式与歌曲快传共用的封面 Pager,保证两处运动效果完全一致。 */
@Composable
internal fun CoverCarouselPager(
state: PagerState,
modifier: Modifier = Modifier,
userScrollEnabled: Boolean = true,
pageContent: @Composable (Int) -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
// 视口宽度比例 0.56,使相邻页更紧凑。
val pageWidth = maxWidth * 0.56f
val pageWidth = maxWidth * CoverCarouselPageFraction
val sidePad = ((maxWidth - pageWidth) / 2f).coerceAtLeast(0.dp)
HorizontalPager(
state = pagerState,
state = state,
contentPadding = PaddingValues(horizontal = sidePad),
pageSize = PageSize.Fixed(pageWidth),
pageSpacing = 0.dp,
beyondViewportPageCount = 1,
userScrollEnabled = userScrollEnabled,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize(),
) { page ->
val pageOffset =
(pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
(state.currentPage - page) + state.currentPageOffsetFraction
val abs = pageOffset.absoluteValue.coerceIn(0f, 1f)
// 缩放淡出变换(scale=0.5, fade=0.3)
val scale = 0.5f + (1f - abs) * 0.5f
val alpha = 0.3f + (1f - abs) * 0.7f
val scale = CoverCarouselSideScale + (1f - abs) * (1f - CoverCarouselSideScale)
val alpha = CoverCarouselSideAlpha + (1f - abs) * (1f - CoverCarouselSideAlpha)
Box(
Modifier
.size(pageWidth)
@@ -106,7 +128,7 @@ fun DriveModeCarousel(
},
contentAlignment = Alignment.Center,
) {
DriveModeCoverCircle(music = queue[page])
pageContent(page)
}
}
}
@@ -116,7 +138,7 @@ fun DriveModeCarousel(
private fun DriveModeCoverCircle(music: Music?) {
Box(
Modifier
.size(CoverSize)
.size(CoverCarouselSize)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
@@ -0,0 +1,291 @@
package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
private const val ThumbResizeDurationMs = 1_000
private const val ThumbFadeDelayMs = 650L
private const val ThumbFadeDurationMs = 250
private data class ScrollbarRequest(
val fraction: Float,
val gestureId: Long,
)
private data class ScrollbarSnapshot(
val totalItems: Int,
val visibleItems: Int,
val firstVisibleIndex: Int,
val firstVisibleOffset: Int,
val firstVisibleSize: Int,
val canScroll: Boolean,
) {
val progress: Float
get() {
val maxFirst = (totalItems - visibleItems).coerceAtLeast(1)
val itemOffset = if (firstVisibleSize > 0) {
firstVisibleOffset.toFloat() / firstVisibleSize
} else {
0f
}
return ((firstVisibleIndex + itemOffset) / maxFirst).coerceIn(0f, 1f)
}
val visibleFraction: Float
get() = if (totalItems > 0) {
(visibleItems.toFloat() / totalItems).coerceIn(0f, 1f)
} else {
1f
}
fun targetIndex(fraction: Float): Int {
val maxFirst = (totalItems - visibleItems).coerceAtLeast(0)
return (fraction.coerceIn(0f, 1f) * maxFirst).roundToInt()
}
}
/** 原 Flutter FlexibleScrollbar 风格:滚动时显示,支持点击轨道和拖动快速定位。 */
@Composable
fun LlmpFlexibleScrollbar(
state: LazyListState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(modifier) {
content()
LazyListScrollbarOverlay(
state = state,
modifier = Modifier.matchParentSize(),
)
}
}
@Composable
private fun LazyListScrollbarOverlay(
state: LazyListState,
modifier: Modifier,
) {
val snapshot by remember(state) {
derivedStateOf {
val layout = state.layoutInfo
val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex }
ScrollbarSnapshot(
totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size,
firstVisibleIndex = state.firstVisibleItemIndex,
firstVisibleOffset = state.firstVisibleItemScrollOffset,
firstVisibleSize = first?.size ?: 0,
canScroll = state.canScrollBackward || state.canScrollForward,
)
}
}
LlmpFlexibleScrollbarOverlay(
snapshot = snapshot,
isScrollInProgress = state.isScrollInProgress,
modifier = modifier,
requestScrollToItem = state::requestScrollToItem,
)
}
/** LazyVerticalGrid 版本,专辑 Page 也使用与列表 Page 一致的滑动条。 */
@Composable
fun LlmpFlexibleScrollbar(
state: LazyGridState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(modifier) {
content()
LazyGridScrollbarOverlay(
state = state,
modifier = Modifier.matchParentSize(),
)
}
}
@Composable
private fun LazyGridScrollbarOverlay(
state: LazyGridState,
modifier: Modifier,
) {
val snapshot by remember(state) {
derivedStateOf {
val layout = state.layoutInfo
val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex }
ScrollbarSnapshot(
totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size,
firstVisibleIndex = state.firstVisibleItemIndex,
firstVisibleOffset = state.firstVisibleItemScrollOffset,
firstVisibleSize = first?.size?.height ?: 0,
canScroll = state.canScrollBackward || state.canScrollForward,
)
}
}
LlmpFlexibleScrollbarOverlay(
snapshot = snapshot,
isScrollInProgress = state.isScrollInProgress,
modifier = modifier,
requestScrollToItem = state::requestScrollToItem,
)
}
@Composable
private fun LlmpFlexibleScrollbarOverlay(
snapshot: ScrollbarSnapshot,
isScrollInProgress: Boolean,
modifier: Modifier,
requestScrollToItem: (Int, Int) -> Unit,
) {
var isDragging by remember { mutableStateOf(false) }
var dragFraction by remember { mutableStateOf(0f) }
var showThumb by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
val scrollRequests = remember { Channel<ScrollbarRequest>(Channel.CONFLATED) }
val latestSnapshot by rememberUpdatedState(snapshot)
val latestRequestScrollToItem by rememberUpdatedState(requestScrollToItem)
// 手指事件可能远多于显示帧:只处理每帧最后一次请求,且不重复跳到同一项。
LaunchedEffect(scrollRequests) {
var lastGestureId = Long.MIN_VALUE
var lastTargetIndex = -1
for (initialRequest in scrollRequests) {
withFrameNanos { }
var latestRequest = initialRequest
while (true) {
latestRequest = scrollRequests.tryReceive().getOrNull() ?: break
}
if (latestRequest.gestureId != lastGestureId) {
lastGestureId = latestRequest.gestureId
lastTargetIndex = -1
}
val targetIndex = latestSnapshot.targetIndex(latestRequest.fraction)
if (targetIndex != lastTargetIndex) {
latestRequestScrollToItem(targetIndex, 0)
lastTargetIndex = targetIndex
}
}
}
LaunchedEffect(isScrollInProgress, isDragging, snapshot.canScroll) {
if (snapshot.canScroll && (isScrollInProgress || isDragging)) {
showThumb = true
} else {
delay(ThumbFadeDelayMs)
showThumb = false
}
}
val thumbAlpha by animateFloatAsState(
targetValue = if (showThumb && snapshot.canScroll) 1f else 0f,
animationSpec = tween(ThumbFadeDurationMs),
label = "libraryScrollbarAlpha",
)
val thumbWidth by animateDpAsState(
targetValue = if (isDragging) 12.dp else 10.dp,
animationSpec = tween(ThumbResizeDurationMs),
label = "libraryScrollbarWidth",
)
BoxWithConstraints(modifier) {
if (!snapshot.canScroll) return@BoxWithConstraints
val trackHeightPx = with(density) { maxHeight.toPx() }.coerceAtLeast(1f)
val minThumbPx = with(density) { 40.dp.toPx() }
val thumbHeightPx = (trackHeightPx * snapshot.visibleFraction)
.coerceIn(minThumbPx.coerceAtMost(trackHeightPx), trackHeightPx)
val thumbRangePx = (trackHeightPx - thumbHeightPx).coerceAtLeast(0f)
val thumbOffsetPx = thumbRangePx * if (isDragging) dragFraction else snapshot.progress
val thumbHeight = with(density) { thumbHeightPx.toDp() }
val latestThumbHeightPx by rememberUpdatedState(thumbHeightPx)
val latestTrackHeightPx by rememberUpdatedState(trackHeightPx)
// 24dp 触摸热区覆盖轨道;可点击跳转,也可按住拖动。
Box(
Modifier
.align(Alignment.CenterEnd)
.width(24.dp)
.fillMaxHeight()
.pointerInput(Unit) {
var gestureId = 0L
fun fractionAt(y: Float): Float {
val range = (latestTrackHeightPx - latestThumbHeightPx)
.coerceAtLeast(1f)
return ((y - latestThumbHeightPx / 2f) / range)
.coerceIn(0f, 1f)
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
gestureId++
dragFraction = fractionAt(down.position.y)
isDragging = true
showThumb = true
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
try {
scrollRequests.trySend(
ScrollbarRequest(dragFraction, gestureId),
)
drag(down.id) { change ->
change.consume()
dragFraction = fractionAt(change.position.y)
scrollRequests.trySend(
ScrollbarRequest(dragFraction, gestureId),
)
}
} finally {
isDragging = false
}
}
},
)
Box(
Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(0, thumbOffsetPx.roundToInt()) }
.width(thumbWidth)
.height(thumbHeight)
.alpha(thumbAlpha)
.background(
color = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.6f),
shape = RoundedCornerShape(5.dp),
),
)
}
}
@@ -79,6 +79,7 @@ import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.lyric.translationAt
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.player.PlayerState
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
@@ -550,11 +551,9 @@ private fun CoverPreviewLines(
) {
val positionMs = rememberPlayerField { it.positionMs }
val currentIdx = LrcParser.currentIndex(lyric.main, positionMs)
val line1 = lyric.main.getOrNull(currentIdx)?.text
?: lyric.main.getOrNull(0)?.text.orEmpty()
val line1 = lyric.main.getOrNull(currentIdx)?.text.orEmpty()
val line2 = when {
lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text
?: lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty()
lrcType > 0 -> lyric.translationAt(currentIdx)?.text.orEmpty()
else -> lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty()
}
Column(
@@ -701,7 +700,7 @@ private fun PlayerLyricScroll(
itemsIndexed(
lyric.main,
key = { index, line -> "${line.timeMs}-$index" }) { index, line ->
val translation = lyric.translation.getOrNull(index)?.text
val translation = lyric.translationAt(index)?.text
val isSelected = selecting && index == selectedIndex
val active = !selecting && index == currentIdx
val lineColor = when {
@@ -1,6 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -13,11 +14,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,10 +37,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
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
@@ -57,15 +64,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo
import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler
@@ -76,6 +87,8 @@ import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.AppHeader
import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverCarouselPager
import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverCarouselSize
import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder
import com.zhushenwudi.lovelivemusicplayer.ui.components.DrawerFunctionRow
import com.zhushenwudi.lovelivemusicplayer.ui.components.DriveModeCarousel
@@ -93,6 +106,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlin.time.Duration.Companion.milliseconds
private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true }
@@ -354,8 +368,8 @@ private fun SettingsRoleLogo() {
Spacer(Modifier.width(35.dp))
Box(Modifier.size(9.dp).clip(CircleShape).background(dot))
Spacer(Modifier.width(35.dp))
AsyncImage(
model = Res.getUri("drawable/logo_logo.png"),
Image(
painter = painterResource(Res.drawable.logo_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(32.dp),
@@ -380,6 +394,8 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) {
Modifier
.padding(padding)
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
@@ -438,10 +454,18 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
val connected = state.connected
var showLeaveConfirm by remember { mutableStateOf(false) }
var coverAnimationsIdle by remember { mutableStateOf(true) }
var displayedSong by remember { mutableStateOf(state.current) }
var displayedArtist by remember { mutableStateOf(state.artist) }
KeepScreenOn()
var wasCompleted by remember { mutableStateOf(state.completed) }
LaunchedEffect(state.completed) {
if (state.completed && !wasCompleted) onBack()
if (state.completed && !wasCompleted) {
// 等本帧最后一次封面更新进入动画队列,再等轮播完整播放完毕。
delay(100)
snapshotFlow { coverAnimationsIdle }.first { it }
onBack()
}
wasCompleted = state.completed
}
fun requestBack() {
@@ -455,25 +479,35 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppHeader(title = tr("music_trans"), onBack = { requestBack() })
if (connected) {
if (connected || state.completed) {
Spacer(Modifier.height(35.dp))
TransferCoverBlock(coverUrl = state.coverUrl)
TransferCoverBlock(
coverUrl = state.coverUrl,
coverKey = state.coverKey,
song = state.current,
artist = state.artist,
onIdleChange = { coverAnimationsIdle = it },
onDisplayedSongChange = { song, artist ->
displayedSong = song
displayedArtist = artist
},
)
Spacer(Modifier.height(20.dp))
Column(
Modifier.height(60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (state.current.isNotBlank()) {
if (displayedSong.isNotBlank()) {
Text(
state.current,
displayedSong,
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = hSp(15f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
state.artist,
displayedArtist,
color = ColorMs.Gray999,
fontSize = hSp(12f),
maxLines = 1,
@@ -532,31 +566,125 @@ private fun KeepScreenOn() {
@OptIn(ExperimentalResourceApi::class)
@Composable
private fun TransferCoverBlock(coverUrl: String) {
val dark = LocalDarkTheme.current
private fun TransferCoverBlock(
coverUrl: String,
coverKey: String,
song: String,
artist: String,
onIdleChange: (Boolean) -> Unit,
onDisplayedSongChange: (String, String) -> Unit,
) {
val initialCover = remember { TransferCoverPage(coverKey, coverUrl, song, artist) }
val covers = remember { mutableStateListOf(initialCover) }
val readyCoverKeys = remember { mutableStateListOf<String>() }
val coverUpdates = remember { Channel<TransferCoverPage>(Channel.UNLIMITED) }
val pagerState = rememberPagerState(pageCount = { covers.size })
val latestOnIdleChange by rememberUpdatedState(onIdleChange)
val latestOnDisplayedSongChange by rememberUpdatedState(onDisplayedSongChange)
var pendingUpdates by remember { mutableIntStateOf(0) }
// 类似 post 队列:传输状态可以继续更新,封面逐张等待上一段动画结束后再切换。
LaunchedEffect(coverKey, coverUrl, song, artist) {
pendingUpdates += 1
latestOnIdleChange(false)
coverUpdates.send(TransferCoverPage(coverKey, coverUrl, song, artist))
}
LaunchedEffect(coverUpdates) {
for (nextCover in coverUpdates) {
try {
val lastIndex = covers.lastIndex
if (covers[lastIndex].key == nextCover.key) {
// 同一封面只更新网络/本地地址,不改变 Pager 页码。
if (covers[lastIndex] != nextCover) covers[lastIndex] = nextCover
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
continue
}
covers.add(nextCover)
val targetPage = covers.lastIndex
snapshotFlow { pagerState.pageCount }.first { it > targetPage }
// 右侧页图片准备好后再 post 到下一帧启动滚动,避免空 Painter 滑到中央闪白。
withTimeoutOrNull(2_000.milliseconds) {
snapshotFlow {
nextCover.key.isBlank() || nextCover.key in readyCoverKeys
}.first { it }
}
delay(32.milliseconds)
runCatching { pagerState.animateScrollToPage(targetPage) }
latestOnDisplayedSongChange(nextCover.song, nextCover.artist)
// 给弹簧动画留一点稳定时间,连续快传时不会显得急促。
delay(80.milliseconds)
} finally {
pendingUpdates -= 1
if (pendingUpdates == 0) latestOnIdleChange(true)
}
}
}
CoverCarouselPager(
state = pagerState,
userScrollEnabled = false,
modifier = Modifier
.fillMaxWidth()
.height(343.dp),
) { page ->
val cover = covers[page]
TransferCoverCircle(
cover = cover,
onReady = {
if (cover.key.isNotBlank() && cover.key !in readyCoverKeys) {
readyCoverKeys.add(cover.key)
}
},
)
}
}
private data class TransferCoverPage(
val key: String,
val url: String,
val song: String,
val artist: String,
)
@OptIn(ExperimentalResourceApi::class)
@Composable
private fun TransferCoverCircle(
cover: TransferCoverPage,
onReady: () -> Unit,
) {
var loadFailed by remember(cover.url) { mutableStateOf(false) }
val latestOnReady by rememberUpdatedState(onReady)
Box(
Modifier
.size(300.dp)
.background(if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary),
.size(CoverCarouselSize)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
if (coverUrl.isBlank()) {
AsyncImage(
model = Res.getUri("drawable/logo_logo.png"),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
// 默认图始终垫在底层,即使图片请求重建或超时也不会露出白色空帧。
Image(
painter = painterResource(Res.drawable.logo_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.padding(36.dp),
)
if (cover.url.isBlank() || loadFailed) {
LaunchedEffect(cover.key, loadFailed) { latestOnReady() }
} else {
// 对齐 Flutter TransformerPageView viewportFraction=0.8
LlmpAsyncImage(
model = coverUrl,
model = cover.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(12.dp)),
stableCacheKey = coverUrl,
modifier = Modifier.fillMaxSize(),
stableCacheKey = cover.key,
decodeSize = CoverCarouselSize,
onSuccess = { latestOnReady() },
onError = {
loadFailed = true
latestOnReady()
},
)
}
}
@@ -82,6 +82,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionButtonRadi
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionButtonSize
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconPadding
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconSize
import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpFlexibleScrollbar
import com.zhushenwudi.lovelivemusicplayer.ui.components.MusicCoverImage
import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.SongListItem
@@ -186,28 +187,33 @@ fun SongList(
return
}
val listBottomInset = LocalHomeListBottomInset.current
LazyColumn(
LlmpFlexibleScrollbar(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(music) { item ->
val id = item.musicId.orEmpty()
SongListItem(
music = item,
isPlaying = item.musicId != null && item.musicId == playingId,
onClick = { onPlay(item) },
onPlayNext = {
addMusicPlayNext(item)
PlatformUi.showToast(I18n.t("add_success"))
},
onMore = { moreMusic = item },
selectMode = selectMode,
checked = id.isNotEmpty() && id in checkedIds,
onCheckedChange = { checked ->
if (id.isNotEmpty()) onCheckedChange(id, checked)
},
)
LazyColumn(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(music) { item ->
val id = item.musicId.orEmpty()
SongListItem(
music = item,
isPlaying = item.musicId != null && item.musicId == playingId,
onClick = { onPlay(item) },
onPlayNext = {
addMusicPlayNext(item)
PlatformUi.showToast(I18n.t("add_success"))
},
onMore = { moreMusic = item },
selectMode = selectMode,
checked = id.isNotEmpty() && id in checkedIds,
onCheckedChange = { checked ->
if (id.isNotEmpty()) onCheckedChange(id, checked)
},
)
}
}
}
@@ -246,22 +252,27 @@ private fun AlbumList(
return
}
val listBottomInset = LocalHomeListBottomInset.current
LazyVerticalGrid(
columns = GridCells.Fixed(3),
LlmpFlexibleScrollbar(
state = resolvedGridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = listBottomInset,
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(albums) { album ->
AlbumGridItem(
album = album,
onClick = { album.albumId?.let(onOpen) },
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = resolvedGridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = listBottomInset,
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(albums) { album ->
AlbumGridItem(
album = album,
onClick = { album.albumId?.let(onOpen) },
)
}
}
}
}
@@ -279,13 +290,18 @@ private fun ArtistList(
return
}
val listBottomInset = LocalHomeListBottomInset.current
LazyColumn(
LlmpFlexibleScrollbar(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(artists, key = { it.uid }) { artist ->
ArtistListItem(artist = artist, onClick = { onOpen(artist.uid) })
LazyColumn(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(artists, key = { it.uid }) { artist ->
ArtistListItem(artist = artist, onClick = { onOpen(artist.uid) })
}
}
}
}
@@ -304,81 +320,86 @@ private fun MenuList(
}
val dark = LocalDarkTheme.current
val listBottomInset = LocalHomeListBottomInset.current
LazyColumn(
LlmpFlexibleScrollbar(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(menus, key = { it.id }) { menu ->
val coverMusic = remember(menu.id, menu.music) {
menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) }
}
val openDetail = LocalOpenLibraryDetail.current
Row(
Modifier
.fillMaxWidth()
.height(TwoLineListItemHeight)
.clickable { onOpen(menu.id) }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HeroCoverSlot(
key = HeroKeys.menu(menu.id),
visible = !openDetail.hidesMenu(menu.id),
clipShape = HeroRoundedClip,
slotModifier = Modifier.size(ListLeadingCoverSize),
) { heroMod ->
if (coverMusic != null) {
MusicCoverImage(
music = coverMusic,
size = ListLeadingCoverSize,
radius = 12.dp,
decodeToDisplaySize = true,
modifier = heroMod,
)
} else {
CoverPlaceholder(
label = menu.name.take(1),
size = null,
radius = 12.dp,
modifier = Modifier.fillMaxSize().then(heroMod),
)
LazyColumn(
state = resolvedListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) {
items(menus, key = { it.id }) { menu ->
val coverMusic = remember(menu.id, menu.music) {
menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) }
}
val openDetail = LocalOpenLibraryDetail.current
Row(
Modifier
.fillMaxWidth()
.height(TwoLineListItemHeight)
.clickable { onOpen(menu.id) }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HeroCoverSlot(
key = HeroKeys.menu(menu.id),
visible = !openDetail.hidesMenu(menu.id),
clipShape = HeroRoundedClip,
slotModifier = Modifier.size(ListLeadingCoverSize),
) { heroMod ->
if (coverMusic != null) {
MusicCoverImage(
music = coverMusic,
size = ListLeadingCoverSize,
radius = 12.dp,
decodeToDisplaySize = true,
modifier = heroMod,
)
} else {
CoverPlaceholder(
label = menu.name.take(1),
size = null,
radius = 12.dp,
modifier = Modifier.fillMaxSize().then(heroMod),
)
}
}
TwoLineListTextColumn(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
title = {
Text(
menu.name,
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = ListItemTitleSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
lineHeight = ListItemTitleLineHeight,
)
},
subtitle = {
Text(
if (menu.isPhone) tr("menu_meta_phone", menu.music.size) else tr("menu_meta_pc", menu.music.size),
color = ColorMs.Gray999,
fontSize = ListItemSubtitleSize,
lineHeight = ListItemSubtitleLineHeight,
)
},
)
NeumorphicIconButton(
icon = LlmpIcons.More,
onClick = { onShare(menu) },
size = LibraryActionButtonSize,
iconSize = LibraryActionIconSize,
radius = LibraryActionButtonRadius,
contentPadding = LibraryActionIconPadding,
iconColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC,
hasShadow = false,
modifier = Modifier.padding(horizontal = 3.dp),
)
}
TwoLineListTextColumn(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
title = {
Text(
menu.name,
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = ListItemTitleSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
lineHeight = ListItemTitleLineHeight,
)
},
subtitle = {
Text(
if (menu.isPhone) tr("menu_meta_phone", menu.music.size) else tr("menu_meta_pc", menu.music.size),
color = ColorMs.Gray999,
fontSize = ListItemSubtitleSize,
lineHeight = ListItemSubtitleLineHeight,
)
},
)
NeumorphicIconButton(
icon = LlmpIcons.More,
onClick = { onShare(menu) },
size = LibraryActionButtonSize,
iconSize = LibraryActionIconSize,
radius = LibraryActionButtonRadius,
contentPadding = LibraryActionIconPadding,
iconColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC,
hasShadow = false,
modifier = Modifier.padding(horizontal = 3.dp),
)
}
}
}