fix lyric show and music trans logic
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
+30
-8
@@ -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,
|
||||
) {
|
||||
|
||||
+291
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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 {
|
||||
|
||||
+154
-26
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+127
-106
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
+39
-14
@@ -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
|
||||
|
||||
+11
-2
@@ -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,
|
||||
|
||||
+9
-2
@@ -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,
|
||||
|
||||
+17
-4
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user