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.LrcParser
import com.zhushenwudi.lovelivemusicplayer.lyric.LyricService import com.zhushenwudi.lovelivemusicplayer.lyric.LyricService
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.lyric.translationAt
import com.zhushenwudi.lovelivemusicplayer.network.OssApi import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.network.WsClient import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.network.createHttpClient import com.zhushenwudi.lovelivemusicplayer.network.createHttpClient
@@ -616,7 +617,7 @@ object AppContainer {
val lrcType = lyricService.lrcType val lrcType = lyricService.lrcType
if (lrcType > 0) { if (lrcType > 0) {
var extText = lyric.translation.getOrNull(curIndex)?.text?.replaceLine() var extText = lyric.translationAt(curIndex)?.text?.replaceLine()
if (mainText.isEmpty()) extText = "" if (mainText.isEmpty()) extText = ""
postDesktopLyric(mainText, extText, 0) postDesktopLyric(mainText, extText, 0)
return return
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -27,7 +28,11 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Music
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
/** 中心封面尺寸;侧页在此基础上缩放(较 230 放大 15%)。 */ /** 中心封面尺寸;侧页在此基础上缩放(较 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()) { BoxWithConstraints(modifier.fillMaxSize()) {
// 视口宽度比例 0.56,使相邻页更紧凑。 // 视口宽度比例 0.56,使相邻页更紧凑。
val pageWidth = maxWidth * 0.56f val pageWidth = maxWidth * CoverCarouselPageFraction
val sidePad = ((maxWidth - pageWidth) / 2f).coerceAtLeast(0.dp) val sidePad = ((maxWidth - pageWidth) / 2f).coerceAtLeast(0.dp)
HorizontalPager( HorizontalPager(
state = pagerState, state = state,
contentPadding = PaddingValues(horizontal = sidePad), contentPadding = PaddingValues(horizontal = sidePad),
pageSize = PageSize.Fixed(pageWidth), pageSize = PageSize.Fixed(pageWidth),
pageSpacing = 0.dp, pageSpacing = 0.dp,
beyondViewportPageCount = 1, beyondViewportPageCount = 1,
userScrollEnabled = userScrollEnabled,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { page -> ) { page ->
val pageOffset = val pageOffset =
(pagerState.currentPage - page) + pagerState.currentPageOffsetFraction (state.currentPage - page) + state.currentPageOffsetFraction
val abs = pageOffset.absoluteValue.coerceIn(0f, 1f) val abs = pageOffset.absoluteValue.coerceIn(0f, 1f)
// 缩放淡出变换(scale=0.5, fade=0.3) // 缩放淡出变换(scale=0.5, fade=0.3)
val scale = 0.5f + (1f - abs) * 0.5f val scale = CoverCarouselSideScale + (1f - abs) * (1f - CoverCarouselSideScale)
val alpha = 0.3f + (1f - abs) * 0.7f val alpha = CoverCarouselSideAlpha + (1f - abs) * (1f - CoverCarouselSideAlpha)
Box( Box(
Modifier Modifier
.size(pageWidth) .size(pageWidth)
@@ -106,7 +128,7 @@ fun DriveModeCarousel(
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
DriveModeCoverCircle(music = queue[page]) pageContent(page)
} }
} }
} }
@@ -116,7 +138,7 @@ fun DriveModeCarousel(
private fun DriveModeCoverCircle(music: Music?) { private fun DriveModeCoverCircle(music: Music?) {
Box( Box(
Modifier Modifier
.size(CoverSize) .size(CoverCarouselSize)
.clip(CircleShape), .clip(CircleShape),
contentAlignment = Alignment.Center, 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.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.lyric.translationAt
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.player.PlayerState import com.zhushenwudi.lovelivemusicplayer.player.PlayerState
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
@@ -550,11 +551,9 @@ private fun CoverPreviewLines(
) { ) {
val positionMs = rememberPlayerField { it.positionMs } val positionMs = rememberPlayerField { it.positionMs }
val currentIdx = LrcParser.currentIndex(lyric.main, positionMs) val currentIdx = LrcParser.currentIndex(lyric.main, positionMs)
val line1 = lyric.main.getOrNull(currentIdx)?.text val line1 = lyric.main.getOrNull(currentIdx)?.text.orEmpty()
?: lyric.main.getOrNull(0)?.text.orEmpty()
val line2 = when { val line2 = when {
lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text lrcType > 0 -> lyric.translationAt(currentIdx)?.text.orEmpty()
?: lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty()
else -> lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty() else -> lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty()
} }
Column( Column(
@@ -701,7 +700,7 @@ private fun PlayerLyricScroll(
itemsIndexed( itemsIndexed(
lyric.main, lyric.main,
key = { index, line -> "${line.timeMs}-$index" }) { index, line -> 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 isSelected = selecting && index == selectedIndex
val active = !selecting && index == currentIdx val active = !selecting && index == currentIdx
val lineColor = when { val lineColor = when {
@@ -1,6 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.ui.screens package com.zhushenwudi.lovelivemusicplayer.ui.screens
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,10 +37,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
import com.zhushenwudi.lovelivemusicplayer.AppContainer import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.Const import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res 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.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler 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.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.AppHeader 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.CoverPlaceholder
import com.zhushenwudi.lovelivemusicplayer.ui.components.DrawerFunctionRow import com.zhushenwudi.lovelivemusicplayer.ui.components.DrawerFunctionRow
import com.zhushenwudi.lovelivemusicplayer.ui.components.DriveModeCarousel 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.icons.SvgIcon
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlin.time.Duration.Companion.milliseconds
private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true } private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true }
@@ -354,8 +368,8 @@ private fun SettingsRoleLogo() {
Spacer(Modifier.width(35.dp)) Spacer(Modifier.width(35.dp))
Box(Modifier.size(9.dp).clip(CircleShape).background(dot)) Box(Modifier.size(9.dp).clip(CircleShape).background(dot))
Spacer(Modifier.width(35.dp)) Spacer(Modifier.width(35.dp))
AsyncImage( Image(
model = Res.getUri("drawable/logo_logo.png"), painter = painterResource(Res.drawable.logo_logo),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
@@ -380,6 +394,8 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) {
Modifier Modifier
.padding(padding) .padding(padding)
.fillMaxSize() .fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -438,10 +454,18 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
val connected = state.connected val connected = state.connected
var showLeaveConfirm by remember { mutableStateOf(false) } 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() KeepScreenOn()
var wasCompleted by remember { mutableStateOf(state.completed) } var wasCompleted by remember { mutableStateOf(state.completed) }
LaunchedEffect(state.completed) { LaunchedEffect(state.completed) {
if (state.completed && !wasCompleted) onBack() if (state.completed && !wasCompleted) {
// 等本帧最后一次封面更新进入动画队列,再等轮播完整播放完毕。
delay(100)
snapshotFlow { coverAnimationsIdle }.first { it }
onBack()
}
wasCompleted = state.completed wasCompleted = state.completed
} }
fun requestBack() { fun requestBack() {
@@ -455,25 +479,35 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AppHeader(title = tr("music_trans"), onBack = { requestBack() }) AppHeader(title = tr("music_trans"), onBack = { requestBack() })
if (connected) { if (connected || state.completed) {
Spacer(Modifier.height(35.dp)) 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)) Spacer(Modifier.height(20.dp))
Column( Column(
Modifier.height(60.dp), Modifier.height(60.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
if (state.current.isNotBlank()) { if (displayedSong.isNotBlank()) {
Text( Text(
state.current, displayedSong,
color = if (dark) ColorMs.White else ColorMs.Text333, color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = hSp(15f), fontSize = hSp(15f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
state.artist, displayedArtist,
color = ColorMs.Gray999, color = ColorMs.Gray999,
fontSize = hSp(12f), fontSize = hSp(12f),
maxLines = 1, maxLines = 1,
@@ -532,31 +566,125 @@ private fun KeepScreenOn() {
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
private fun TransferCoverBlock(coverUrl: String) { private fun TransferCoverBlock(
val dark = LocalDarkTheme.current 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( Box(
Modifier Modifier
.size(300.dp) .size(CoverCarouselSize)
.background(if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary), .clip(CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (coverUrl.isBlank()) { // 默认图始终垫在底层,即使图片请求重建或超时也不会露出白色空帧。
AsyncImage( Image(
model = Res.getUri("drawable/logo_logo.png"), painter = painterResource(Res.drawable.logo_logo),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier
) .fillMaxSize()
.padding(36.dp),
)
if (cover.url.isBlank() || loadFailed) {
LaunchedEffect(cover.key, loadFailed) { latestOnReady() }
} else { } else {
// 对齐 Flutter TransformerPageView viewportFraction=0.8
LlmpAsyncImage( LlmpAsyncImage(
model = coverUrl, model = cover.url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.size(240.dp) stableCacheKey = cover.key,
.clip(RoundedCornerShape(12.dp)), decodeSize = CoverCarouselSize,
stableCacheKey = coverUrl, 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.LibraryActionButtonSize
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconPadding import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconPadding
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconSize 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.MusicCoverImage
import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.SongListItem import com.zhushenwudi.lovelivemusicplayer.ui.components.SongListItem
@@ -186,28 +187,33 @@ fun SongList(
return return
} }
val listBottomInset = LocalHomeListBottomInset.current val listBottomInset = LocalHomeListBottomInset.current
LazyColumn( LlmpFlexibleScrollbar(
state = resolvedListState, state = resolvedListState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) { ) {
items(music) { item -> LazyColumn(
val id = item.musicId.orEmpty() state = resolvedListState,
SongListItem( modifier = Modifier.fillMaxSize(),
music = item, contentPadding = PaddingValues(bottom = listBottomInset),
isPlaying = item.musicId != null && item.musicId == playingId, ) {
onClick = { onPlay(item) }, items(music) { item ->
onPlayNext = { val id = item.musicId.orEmpty()
addMusicPlayNext(item) SongListItem(
PlatformUi.showToast(I18n.t("add_success")) music = item,
}, isPlaying = item.musicId != null && item.musicId == playingId,
onMore = { moreMusic = item }, onClick = { onPlay(item) },
selectMode = selectMode, onPlayNext = {
checked = id.isNotEmpty() && id in checkedIds, addMusicPlayNext(item)
onCheckedChange = { checked -> PlatformUi.showToast(I18n.t("add_success"))
if (id.isNotEmpty()) onCheckedChange(id, checked) },
}, 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 return
} }
val listBottomInset = LocalHomeListBottomInset.current val listBottomInset = LocalHomeListBottomInset.current
LazyVerticalGrid( LlmpFlexibleScrollbar(
columns = GridCells.Fixed(3),
state = resolvedGridState, state = resolvedGridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = listBottomInset,
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
items(albums) { album -> LazyVerticalGrid(
AlbumGridItem( columns = GridCells.Fixed(3),
album = album, state = resolvedGridState,
onClick = { album.albumId?.let(onOpen) }, 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 return
} }
val listBottomInset = LocalHomeListBottomInset.current val listBottomInset = LocalHomeListBottomInset.current
LazyColumn( LlmpFlexibleScrollbar(
state = resolvedListState, state = resolvedListState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) { ) {
items(artists, key = { it.uid }) { artist -> LazyColumn(
ArtistListItem(artist = artist, onClick = { onOpen(artist.uid) }) 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 dark = LocalDarkTheme.current
val listBottomInset = LocalHomeListBottomInset.current val listBottomInset = LocalHomeListBottomInset.current
LazyColumn( LlmpFlexibleScrollbar(
state = resolvedListState, state = resolvedListState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = listBottomInset),
) { ) {
items(menus, key = { it.id }) { menu -> LazyColumn(
val coverMusic = remember(menu.id, menu.music) { state = resolvedListState,
menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) } modifier = Modifier.fillMaxSize(),
} contentPadding = PaddingValues(bottom = listBottomInset),
val openDetail = LocalOpenLibraryDetail.current ) {
Row( items(menus, key = { it.id }) { menu ->
Modifier val coverMusic = remember(menu.id, menu.music) {
.fillMaxWidth() menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) }
.height(TwoLineListItemHeight) }
.clickable { onOpen(menu.id) } val openDetail = LocalOpenLibraryDetail.current
.padding(horizontal = 16.dp), Row(
verticalAlignment = Alignment.CenterVertically, Modifier
) { .fillMaxWidth()
HeroCoverSlot( .height(TwoLineListItemHeight)
key = HeroKeys.menu(menu.id), .clickable { onOpen(menu.id) }
visible = !openDetail.hidesMenu(menu.id), .padding(horizontal = 16.dp),
clipShape = HeroRoundedClip, verticalAlignment = Alignment.CenterVertically,
slotModifier = Modifier.size(ListLeadingCoverSize), ) {
) { heroMod -> HeroCoverSlot(
if (coverMusic != null) { key = HeroKeys.menu(menu.id),
MusicCoverImage( visible = !openDetail.hidesMenu(menu.id),
music = coverMusic, clipShape = HeroRoundedClip,
size = ListLeadingCoverSize, slotModifier = Modifier.size(ListLeadingCoverSize),
radius = 12.dp, ) { heroMod ->
decodeToDisplaySize = true, if (coverMusic != null) {
modifier = heroMod, MusicCoverImage(
) music = coverMusic,
} else { size = ListLeadingCoverSize,
CoverPlaceholder( radius = 12.dp,
label = menu.name.take(1), decodeToDisplaySize = true,
size = null, modifier = heroMod,
radius = 12.dp, )
modifier = Modifier.fillMaxSize().then(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.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.Music import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
private val json = Json { ignoreUnknownKeys = true } 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 路径变更后、无需全量重导入时使用。 * 存储根路径或 SD 路径变更后、无需全量重导入时使用。
*/ */
fun rescanExistFiles(paths: PathResolver): Int { fun rescanExistFiles(paths: PathResolver): Int {
var changed = 0 var changed = 0
val albumHasFile = mutableMapOf<String, Boolean>() val albumHasFile = mutableMapOf<String, Boolean>()
val albumCoverPaths = mutableMapOf<String, String>()
getAllMusic().forEach { music -> getAllMusic().forEach { music ->
val updated = paths.refreshExistFile(music) val updated = paths.refreshExistFile(music)
if (updated.existFile != music.existFile) { if (updated.existFile != music.existFile) {
@@ -144,6 +147,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
changed++ changed++
} }
val albumId = updated.albumId ?: return@forEach val albumId = updated.albumId ?: return@forEach
joinMediaRelativePath(updated.baseUrl, updated.coverPath)?.let { coverPath ->
albumCoverPaths.putIfAbsent(albumId, coverPath)
}
if (updated.existFile == true) { if (updated.existFile == true) {
albumHasFile[albumId] = true albumHasFile[albumId] = true
} else { } else {
@@ -155,8 +161,22 @@ class LibraryRepository(private val db: LlmpDatabase) {
getAllAlbums().forEach { album -> getAllAlbums().forEach { album ->
val id = album.albumId ?: return@forEach val id = album.albumId ?: return@forEach
val shouldExist = albumHasFile[id] == true val shouldExist = albumHasFile[id] == true
if (album.existFile != shouldExist) { val storedCoverPath = album.coverPath?.trim().orEmpty()
upsertAlbum(album.copy(existFile = shouldExist)) 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++ changed++
} }
} }
@@ -6,8 +6,8 @@ data class LyricLine(
) )
object LrcParser { object LrcParser {
private val lineRegex = private val timeTagRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?](.*)""") Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?]""")
fun parse(lrc: String?): List<LyricLine> { fun parse(lrc: String?): List<LyricLine> {
if (lrc.isNullOrBlank()) return emptyList() if (lrc.isNullOrBlank()) return emptyList()
@@ -15,23 +15,48 @@ object LrcParser {
for (raw in lrc.lineSequence()) { for (raw in lrc.lineSequence()) {
val trimmed = raw.trim() val trimmed = raw.trim()
if (trimmed.isEmpty()) continue if (trimmed.isEmpty()) continue
val match = lineRegex.find(trimmed) ?: continue val matches = timeTagRegex.findAll(trimmed).toList()
val min = match.groupValues[1].toLongOrNull() ?: continue if (matches.isEmpty()) continue
val sec = match.groupValues[2].toLongOrNull() ?: continue val text = trimmed.substring(matches.last().range.last + 1).trim()
val frac = match.groupValues[3] for (match in matches) {
val millis = when { val min = match.groupValues[1].toLongOrNull() ?: continue
frac.isEmpty() -> 0L val sec = match.groupValues[2].toLongOrNull() ?: continue
frac.length == 1 -> frac.toLong() * 100 val frac = match.groupValues[3]
frac.length == 2 -> frac.toLong() * 10 val millis = when {
else -> frac.take(3).padEnd(3, '0').toLong() 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 } 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 { fun currentIndex(lines: List<LyricLine>, positionMs: Long): Int {
if (lines.isEmpty()) return -1 if (lines.isEmpty()) return -1
var idx = -1 var idx = -1
@@ -154,9 +154,11 @@ class LyricService(
translation = lyric.roma translation = lyric.roma
} }
} }
val mainLines = LrcParser.parse(main?.takeIf { it.isNotBlank() })
val translationLines = LrcParser.parse(translation?.takeIf { it.isNotBlank() })
return LoadedLyric( return LoadedLyric(
main = LrcParser.parse(main?.takeIf { it.isNotBlank() }), main = mainLines,
translation = LrcParser.parse(translation?.takeIf { it.isNotBlank() }), translation = LrcParser.alignByTime(mainLines, translationLines),
raw = lyric, raw = lyric,
) )
} }
@@ -168,6 +170,13 @@ data class LoadedLyric(
val raw: Lyric? = null, 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( data class LyricFetchResult(
val loaded: LoadedLyric, val loaded: LoadedLyric,
val ok: Boolean, val ok: Boolean,
@@ -26,6 +26,7 @@ import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
data class TransferUiState( data class TransferUiState(
val host: String = "", val host: String = "",
@@ -36,6 +37,7 @@ data class TransferUiState(
val current: String = "", val current: String = "",
val artist: String = "", val artist: String = "",
val coverUrl: String = "", val coverUrl: String = "",
val coverKey: String = "",
val progress: Int = 0, val progress: Int = 0,
val total: Int = 0, val total: Int = 0,
val error: String? = null, val error: String? = null,
@@ -174,6 +176,7 @@ class MusicTransferService(
current = item.musicName, current = item.musicName,
artist = item.artist, artist = item.artist,
coverUrl = coverHttpUrl(item), coverUrl = coverHttpUrl(item),
coverKey = coverKey(item),
status = I18n.t("downloading_song", item.musicName), status = I18n.t("downloading_song", item.musicName),
) )
} }
@@ -248,7 +251,10 @@ class MusicTransferService(
} }
private fun coverHttpUrl(item: DownloadMusic): String = 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) { private fun importOne(item: DownloadMusic) {
library.upsertAlbum( library.upsertAlbum(
@@ -256,7 +262,8 @@ class MusicTransferService(
albumId = item.albumUId, albumId = item.albumUId,
albumName = item.albumName, albumName = item.albumName,
date = item.date, date = item.date,
coverPath = item.coverPath, // Album 没有 baseUrl 字段,必须保存可独立解析的完整相对路径。
coverPath = coverKey(item).ifBlank { null },
category = item.category, category = item.category,
group = item.group, group = item.group,
existFile = true, existFile = true,
@@ -8,6 +8,19 @@ import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings 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( class PathResolver(
private val fs: FileSystem, private val fs: FileSystem,
private val settings: AppSettings, private val settings: AppSettings,
@@ -56,9 +69,8 @@ class PathResolver(
} }
fun absoluteCoverPath(music: Music): String? { fun absoluteCoverPath(music: Music): String? {
val base = music.baseUrl ?: return null val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
val path = music.coverPath ?: return null return storageRoot() + relative
return storageRoot() + base.trimStart('/') + path.trimStart('/')
} }
fun absoluteAlbumCoverPath(album: Album): String? { fun absoluteAlbumCoverPath(album: Album): String? {
@@ -93,7 +105,8 @@ class PathResolver(
} }
} }
if (settings.enableHttp) { 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 return null
} }