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),
)
}
}
}
@@ -8,6 +8,7 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Lyric
import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
private val json = Json { ignoreUnknownKeys = true }
@@ -131,12 +132,14 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
/**
* 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`。
* 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`,同时修复
* 旧版快传写入的专辑单文件名封面路径。
* 存储根路径或 SD 路径变更后、无需全量重导入时使用。
*/
fun rescanExistFiles(paths: PathResolver): Int {
var changed = 0
val albumHasFile = mutableMapOf<String, Boolean>()
val albumCoverPaths = mutableMapOf<String, String>()
getAllMusic().forEach { music ->
val updated = paths.refreshExistFile(music)
if (updated.existFile != music.existFile) {
@@ -144,6 +147,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
changed++
}
val albumId = updated.albumId ?: return@forEach
joinMediaRelativePath(updated.baseUrl, updated.coverPath)?.let { coverPath ->
albumCoverPaths.putIfAbsent(albumId, coverPath)
}
if (updated.existFile == true) {
albumHasFile[albumId] = true
} else {
@@ -155,8 +161,22 @@ class LibraryRepository(private val db: LlmpDatabase) {
getAllAlbums().forEach { album ->
val id = album.albumId ?: return@forEach
val shouldExist = albumHasFile[id] == true
if (album.existFile != shouldExist) {
upsertAlbum(album.copy(existFile = shouldExist))
val storedCoverPath = album.coverPath?.trim().orEmpty()
val repairedCoverPath = if (
storedCoverPath.isEmpty() ||
(!storedCoverPath.contains('/') && !storedCoverPath.contains('\\'))
) {
albumCoverPaths[id] ?: album.coverPath
} else {
album.coverPath
}
if (album.existFile != shouldExist || album.coverPath != repairedCoverPath) {
upsertAlbum(
album.copy(
coverPath = repairedCoverPath,
existFile = shouldExist,
),
)
changed++
}
}
@@ -6,8 +6,8 @@ data class LyricLine(
)
object LrcParser {
private val lineRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?](.*)""")
private val timeTagRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?]""")
fun parse(lrc: String?): List<LyricLine> {
if (lrc.isNullOrBlank()) return emptyList()
@@ -15,23 +15,48 @@ object LrcParser {
for (raw in lrc.lineSequence()) {
val trimmed = raw.trim()
if (trimmed.isEmpty()) continue
val match = lineRegex.find(trimmed) ?: continue
val min = match.groupValues[1].toLongOrNull() ?: continue
val sec = match.groupValues[2].toLongOrNull() ?: continue
val frac = match.groupValues[3]
val millis = when {
frac.isEmpty() -> 0L
frac.length == 1 -> frac.toLong() * 100
frac.length == 2 -> frac.toLong() * 10
else -> frac.take(3).padEnd(3, '0').toLong()
val matches = timeTagRegex.findAll(trimmed).toList()
if (matches.isEmpty()) continue
val text = trimmed.substring(matches.last().range.last + 1).trim()
for (match in matches) {
val min = match.groupValues[1].toLongOrNull() ?: continue
val sec = match.groupValues[2].toLongOrNull() ?: continue
val frac = match.groupValues[3]
val millis = when {
frac.isEmpty() -> 0L
frac.length == 1 -> frac.toLong() * 100
frac.length == 2 -> frac.toLong() * 10
else -> frac.take(3).padEnd(3, '0').toLong()
}
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
}
val text = match.groupValues[4].trim()
if (text.isEmpty()) continue
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
}
return lines.sortedBy { it.timeMs }
}
/**
* 按 LRC 时间标签把扩展歌词对齐到原文;缺失标签保留空行,避免后续歌词整体错位。
* 同一时间存在多行时,按各自在源文件中的出现顺序一一配对。
*/
fun alignByTime(
main: List<LyricLine>,
extension: List<LyricLine>,
): List<LyricLine> {
val extensionByTime = extension.groupBy { it.timeMs }
val usedAtTime = mutableMapOf<Long, Int>()
return main.map { mainLine ->
val occurrence = usedAtTime[mainLine.timeMs] ?: 0
usedAtTime[mainLine.timeMs] = occurrence + 1
LyricLine(
timeMs = mainLine.timeMs,
text = extensionByTime[mainLine.timeMs]
?.getOrNull(occurrence)
?.text
.orEmpty(),
)
}
}
fun currentIndex(lines: List<LyricLine>, positionMs: Long): Int {
if (lines.isEmpty()) return -1
var idx = -1
@@ -154,9 +154,11 @@ class LyricService(
translation = lyric.roma
}
}
val mainLines = LrcParser.parse(main?.takeIf { it.isNotBlank() })
val translationLines = LrcParser.parse(translation?.takeIf { it.isNotBlank() })
return LoadedLyric(
main = LrcParser.parse(main?.takeIf { it.isNotBlank() }),
translation = LrcParser.parse(translation?.takeIf { it.isNotBlank() }),
main = mainLines,
translation = LrcParser.alignByTime(mainLines, translationLines),
raw = lyric,
)
}
@@ -168,6 +170,13 @@ data class LoadedLyric(
val raw: Lyric? = null,
)
/** 始终按原文时间标签取译文,避免调用方重新退化为下标配对。 */
fun LoadedLyric.translationAt(mainIndex: Int): LyricLine? {
val mainLine = main.getOrNull(mainIndex) ?: return null
return translation.getOrNull(mainIndex)?.takeIf { it.timeMs == mainLine.timeMs }
?: translation.firstOrNull { it.timeMs == mainLine.timeMs }
}
data class LyricFetchResult(
val loaded: LoadedLyric,
val ok: Boolean,
@@ -26,6 +26,7 @@ import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
data class TransferUiState(
val host: String = "",
@@ -36,6 +37,7 @@ data class TransferUiState(
val current: String = "",
val artist: String = "",
val coverUrl: String = "",
val coverKey: String = "",
val progress: Int = 0,
val total: Int = 0,
val error: String? = null,
@@ -174,6 +176,7 @@ class MusicTransferService(
current = item.musicName,
artist = item.artist,
coverUrl = coverHttpUrl(item),
coverKey = coverKey(item),
status = I18n.t("downloading_song", item.musicName),
)
}
@@ -248,7 +251,10 @@ class MusicTransferService(
}
private fun coverHttpUrl(item: DownloadMusic): String =
fileHttpUrl(item.baseUrl, item.coverPath)
if (item.coverPath.isBlank()) "" else fileHttpUrl(item.baseUrl, item.coverPath)
private fun coverKey(item: DownloadMusic): String =
joinMediaRelativePath(item.baseUrl, item.coverPath).orEmpty()
private fun importOne(item: DownloadMusic) {
library.upsertAlbum(
@@ -256,7 +262,8 @@ class MusicTransferService(
albumId = item.albumUId,
albumName = item.albumName,
date = item.date,
coverPath = item.coverPath,
// Album 没有 baseUrl 字段,必须保存可独立解析的完整相对路径。
coverPath = coverKey(item).ifBlank { null },
category = item.category,
group = item.group,
existFile = true,
@@ -8,6 +8,19 @@ import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
/**
* 将歌曲的目录和文件相对路径合并成专辑可独立使用的完整相对路径。
* 已经是完整路径时不重复拼接,兼容云端导入和旧数据库数据。
*/
internal fun joinMediaRelativePath(baseUrl: String?, path: String?): String? {
val child = path?.trim()?.trim('/', '\\').orEmpty()
if (child.isEmpty()) return null
val base = baseUrl?.trim()?.trim('/', '\\').orEmpty()
if (base.isEmpty()) return child
if (child == base || child.startsWith("$base/")) return child
return "$base/$child"
}
class PathResolver(
private val fs: FileSystem,
private val settings: AppSettings,
@@ -56,9 +69,8 @@ class PathResolver(
}
fun absoluteCoverPath(music: Music): String? {
val base = music.baseUrl ?: return null
val path = music.coverPath ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return storageRoot() + relative
}
fun absoluteAlbumCoverPath(album: Album): String? {
@@ -93,7 +105,8 @@ class PathResolver(
}
}
if (settings.enableHttp) {
return joinHttpUrl(music.baseUrl.orEmpty(), music.coverPath.orEmpty())
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return joinHttpUrl(relative)
}
return null
}