diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt index 0091e1f..6ab13c2 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt index d49c234..6a208eb 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt new file mode 100644 index 0000000..e4282ae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt @@ -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(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), + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt index 5a2f449..565a0d7 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt index 2c71dd1..21ae662 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt @@ -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() } + val coverUpdates = remember { Channel(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() + }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt index 2fab951..0202486 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt @@ -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), - ) } } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt index bdbf97e..bd54a97 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt @@ -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() + val albumCoverPaths = mutableMapOf() 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++ } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt index 0156c8a..f46a8b9 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt @@ -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 { 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, + extension: List, + ): List { + val extensionByTime = extension.groupBy { it.timeMs } + val usedAtTime = mutableMapOf() + 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, positionMs: Long): Int { if (lines.isEmpty()) return -1 var idx = -1 diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt index 8b74ef9..4f4955b 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt index 9b22e96..d3ba6e5 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt index f1a42d7..90edddd 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt @@ -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 }