Compare commits

...
2 Commits
Author SHA1 Message Date
zhushenwudi 53897bda1a optimize orientation container position 2026-10-03 19:07:41 +08:00
zhushenwudi 52ab485e9c optimize photo scroll smoothly 2026-10-03 18:03:37 +08:00
15 changed files with 880 additions and 453 deletions
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.zhushenwudi.lovelivemusicplayer.domain.Album import com.zhushenwudi.lovelivemusicplayer.domain.Album
@@ -24,10 +25,15 @@ import com.zhushenwudi.lovelivemusicplayer.navigation.hidesAlbum
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
/**
* @param cellWidth 网格单元宽度,用来决定封面解码尺寸。
* 由网格统一算好传进来,省掉每个单元各量一次的子组合。
*/
@Composable @Composable
fun AlbumGridItem( fun AlbumGridItem(
album: Album, album: Album,
onClick: () -> Unit, onClick: () -> Unit,
cellWidth: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
@@ -38,7 +44,10 @@ fun AlbumGridItem(
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
// Material 3 双行网格单元内边距。 // Material 3 双行网格单元内边距。
.padding(horizontal = 5.dp, vertical = AlbumGridItemVerticalPadding), .padding(
horizontal = AlbumGridItemHorizontalPadding,
vertical = AlbumGridItemVerticalPadding,
),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
HeroCoverSlot( HeroCoverSlot(
@@ -52,7 +61,8 @@ fun AlbumGridItem(
AlbumCoverImage( AlbumCoverImage(
album = album, album = album,
radius = 12.dp, radius = 12.dp,
decodeToDisplaySize = true, decodeSize = (cellWidth - AlbumGridItemHorizontalPadding * 2)
.takeIf { it > 0.dp },
modifier = Modifier.fillMaxSize().then(heroMod), modifier = Modifier.fillMaxSize().then(heroMod),
) )
} }
@@ -68,7 +68,7 @@ private object DrawerLayout {
val headerToGroupsGap = 10.dp val headerToGroupsGap = 10.dp
val groupRowGap = 12.dp val groupRowGap = 12.dp
val groupsToMenuGap = 10.dp val groupsToMenuGap = 10.dp
val groupButtonWidth = 120.dp // 宽度不固定:按容器宽度两等分,才能和功能菜单的左右边距对齐。设计稿为 120dp。
val groupButtonHeight = 50.dp val groupButtonHeight = 50.dp
val groupButtonGap = 10.dp val groupButtonGap = 10.dp
val menuOuterPaddingH = 8.dp val menuOuterPaddingH = 8.dp
@@ -221,7 +221,10 @@ fun AppDrawer(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
groupRows.forEachIndexed { index, row -> groupRows.forEachIndexed { index, row ->
// 必须撑满并等分:固定宽度会被外层 Column 的 CenterHorizontally 居中,
// 平板上抽屉变宽后就和下面左对齐的功能菜单差出一截边距。
Row( Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(DrawerLayout.groupButtonGap), horizontalArrangement = Arrangement.spacedBy(DrawerLayout.groupButtonGap),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -232,7 +235,7 @@ fun AppDrawer(
selected = currentGroup == group.key, selected = currentGroup == group.key,
onClick = { onGroupSelect(group.key) }, onClick = { onGroupSelect(group.key) },
modifier = Modifier modifier = Modifier
.width(DrawerLayout.groupButtonWidth) .weight(1f)
.height(DrawerLayout.groupButtonHeight), .height(DrawerLayout.groupButtonHeight),
) )
} }
@@ -313,7 +316,9 @@ private fun GroupButton(
AsyncImage( AsyncImage(
model = Res.getUri(group.asset.path), model = Res.getUri(group.asset.path),
contentDescription = group.key.displayName, contentDescription = group.key.displayName,
contentScale = ContentScale.FillWidth, // Fit 而非 FillWidth:按钮改为等分后在平板上更宽,
// 撑满宽度会把 logo 顶出 50dp 的高度被裁掉。
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -17,6 +17,7 @@ internal val ListItemOuterVerticalPadding = 0.dp
/** 专辑网格单元外内边距。 */ /** 专辑网格单元外内边距。 */
internal val AlbumGridItemVerticalPadding = 8.dp internal val AlbumGridItemVerticalPadding = 8.dp
internal val AlbumGridItemHorizontalPadding = 5.dp
/** 列表行主标题(歌名/艺人名/歌单名)。 */ /** 列表行主标题(歌名/艺人名/歌单名)。 */
internal val ListItemTitleSize = 13.sp internal val ListItemTitleSize = 13.sp
@@ -8,31 +8,32 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -43,30 +44,24 @@ private const val ThumbResizeDurationMs = 1_000
private const val ThumbFadeDelayMs = 650L private const val ThumbFadeDelayMs = 650L
private const val ThumbFadeDurationMs = 250 private const val ThumbFadeDurationMs = 250
private val ThumbMinHeight = 40.dp
private val ThumbTouchTargetWidth = 24.dp
private data class ScrollbarRequest( private data class ScrollbarRequest(
val fraction: Float, val fraction: Float,
val gestureId: Long, val gestureId: Long,
) )
private data class ScrollbarSnapshot( /**
* 滑块几何只依赖这几个低频量。
* 逐帧变化的滚动进度单独放在 [scrollProgress],只在布局阶段读取 —— 否则
* 滚动的每一帧都会重组整个滑块子树。
*/
private data class ScrollbarMetrics(
val totalItems: Int, val totalItems: Int,
val visibleItems: Int, val visibleItems: Int,
val firstVisibleIndex: Int,
val firstVisibleOffset: Int,
val firstVisibleSize: Int,
val canScroll: Boolean, 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 val visibleFraction: Float
get() = if (totalItems > 0) { get() = if (totalItems > 0) {
(visibleItems.toFloat() / totalItems).coerceIn(0f, 1f) (visibleItems.toFloat() / totalItems).coerceIn(0f, 1f)
@@ -80,6 +75,22 @@ private data class ScrollbarSnapshot(
} }
} }
private fun scrollProgress(
totalItems: Int,
visibleItems: Int,
firstVisibleIndex: Int,
firstVisibleOffset: Int,
firstVisibleSize: Int,
): Float {
val maxFirst = (totalItems - visibleItems).coerceAtLeast(1)
val itemOffset = if (firstVisibleSize > 0) {
firstVisibleOffset.toFloat() / firstVisibleSize
} else {
0f
}
return ((firstVisibleIndex + itemOffset) / maxFirst).coerceIn(0f, 1f)
}
/** 原 Flutter FlexibleScrollbar 风格:滚动时显示,支持点击轨道和拖动快速定位。 */ /** 原 Flutter FlexibleScrollbar 风格:滚动时显示,支持点击轨道和拖动快速定位。 */
@Composable @Composable
fun LlmpFlexibleScrollbar( fun LlmpFlexibleScrollbar(
@@ -101,22 +112,33 @@ private fun LazyListScrollbarOverlay(
state: LazyListState, state: LazyListState,
modifier: Modifier, modifier: Modifier,
) { ) {
val snapshot by remember(state) { val metrics = remember(state) {
derivedStateOf(structuralEqualityPolicy()) {
val layout = state.layoutInfo
ScrollbarMetrics(
totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size,
canScroll = state.canScrollBackward || state.canScrollForward,
)
}
}
val progress = remember(state) {
derivedStateOf { derivedStateOf {
val layout = state.layoutInfo val layout = state.layoutInfo
val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex } val first = layout.visibleItemsInfo
ScrollbarSnapshot( .firstOrNull { it.index == state.firstVisibleItemIndex }
scrollProgress(
totalItems = layout.totalItemsCount, totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size, visibleItems = layout.visibleItemsInfo.size,
firstVisibleIndex = state.firstVisibleItemIndex, firstVisibleIndex = state.firstVisibleItemIndex,
firstVisibleOffset = state.firstVisibleItemScrollOffset, firstVisibleOffset = state.firstVisibleItemScrollOffset,
firstVisibleSize = first?.size ?: 0, firstVisibleSize = first?.size ?: 0,
canScroll = state.canScrollBackward || state.canScrollForward,
) )
} }
} }
LlmpFlexibleScrollbarOverlay( LlmpFlexibleScrollbarOverlay(
snapshot = snapshot, metrics = metrics,
progress = progress,
isScrollInProgress = state.isScrollInProgress, isScrollInProgress = state.isScrollInProgress,
modifier = modifier, modifier = modifier,
requestScrollToItem = state::requestScrollToItem, requestScrollToItem = state::requestScrollToItem,
@@ -144,22 +166,33 @@ private fun LazyGridScrollbarOverlay(
state: LazyGridState, state: LazyGridState,
modifier: Modifier, modifier: Modifier,
) { ) {
val snapshot by remember(state) { val metrics = remember(state) {
derivedStateOf(structuralEqualityPolicy()) {
val layout = state.layoutInfo
ScrollbarMetrics(
totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size,
canScroll = state.canScrollBackward || state.canScrollForward,
)
}
}
val progress = remember(state) {
derivedStateOf { derivedStateOf {
val layout = state.layoutInfo val layout = state.layoutInfo
val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex } val first = layout.visibleItemsInfo
ScrollbarSnapshot( .firstOrNull { it.index == state.firstVisibleItemIndex }
scrollProgress(
totalItems = layout.totalItemsCount, totalItems = layout.totalItemsCount,
visibleItems = layout.visibleItemsInfo.size, visibleItems = layout.visibleItemsInfo.size,
firstVisibleIndex = state.firstVisibleItemIndex, firstVisibleIndex = state.firstVisibleItemIndex,
firstVisibleOffset = state.firstVisibleItemScrollOffset, firstVisibleOffset = state.firstVisibleItemScrollOffset,
firstVisibleSize = first?.size?.height ?: 0, firstVisibleSize = first?.size?.height ?: 0,
canScroll = state.canScrollBackward || state.canScrollForward,
) )
} }
} }
LlmpFlexibleScrollbarOverlay( LlmpFlexibleScrollbarOverlay(
snapshot = snapshot, metrics = metrics,
progress = progress,
isScrollInProgress = state.isScrollInProgress, isScrollInProgress = state.isScrollInProgress,
modifier = modifier, modifier = modifier,
requestScrollToItem = state::requestScrollToItem, requestScrollToItem = state::requestScrollToItem,
@@ -168,19 +201,21 @@ private fun LazyGridScrollbarOverlay(
@Composable @Composable
private fun LlmpFlexibleScrollbarOverlay( private fun LlmpFlexibleScrollbarOverlay(
snapshot: ScrollbarSnapshot, metrics: State<ScrollbarMetrics>,
progress: State<Float>,
isScrollInProgress: Boolean, isScrollInProgress: Boolean,
modifier: Modifier, modifier: Modifier,
requestScrollToItem: (Int, Int) -> Unit, requestScrollToItem: (Int, Int) -> Unit,
) { ) {
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
var dragFraction by remember { mutableStateOf(0f) }
var showThumb by remember { mutableStateOf(false) } var showThumb by remember { mutableStateOf(false) }
// 拖动中逐帧变化,只在布局阶段读取。
val dragFraction = remember { mutableFloatStateOf(0f) }
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
val scrollRequests = remember { Channel<ScrollbarRequest>(Channel.CONFLATED) } val scrollRequests = remember { Channel<ScrollbarRequest>(Channel.CONFLATED) }
val latestSnapshot by rememberUpdatedState(snapshot)
val latestRequestScrollToItem by rememberUpdatedState(requestScrollToItem) val latestRequestScrollToItem by rememberUpdatedState(requestScrollToItem)
val canScroll = metrics.value.canScroll
// 手指事件可能远多于显示帧:只处理每帧最后一次请求,且不重复跳到同一项。 // 手指事件可能远多于显示帧:只处理每帧最后一次请求,且不重复跳到同一项。
LaunchedEffect(scrollRequests) { LaunchedEffect(scrollRequests) {
@@ -196,7 +231,7 @@ private fun LlmpFlexibleScrollbarOverlay(
lastGestureId = latestRequest.gestureId lastGestureId = latestRequest.gestureId
lastTargetIndex = -1 lastTargetIndex = -1
} }
val targetIndex = latestSnapshot.targetIndex(latestRequest.fraction) val targetIndex = metrics.value.targetIndex(latestRequest.fraction)
if (targetIndex != lastTargetIndex) { if (targetIndex != lastTargetIndex) {
latestRequestScrollToItem(targetIndex, 0) latestRequestScrollToItem(targetIndex, 0)
lastTargetIndex = targetIndex lastTargetIndex = targetIndex
@@ -204,8 +239,8 @@ private fun LlmpFlexibleScrollbarOverlay(
} }
} }
LaunchedEffect(isScrollInProgress, isDragging, snapshot.canScroll) { LaunchedEffect(isScrollInProgress, isDragging, canScroll) {
if (snapshot.canScroll && (isScrollInProgress || isDragging)) { if (canScroll && (isScrollInProgress || isDragging)) {
showThumb = true showThumb = true
} else { } else {
delay(ThumbFadeDelayMs.milliseconds) delay(ThumbFadeDelayMs.milliseconds)
@@ -214,7 +249,7 @@ private fun LlmpFlexibleScrollbarOverlay(
} }
val thumbAlpha by animateFloatAsState( val thumbAlpha by animateFloatAsState(
targetValue = if (showThumb && snapshot.canScroll) 1f else 0f, targetValue = if (showThumb && canScroll) 1f else 0f,
animationSpec = tween(ThumbFadeDurationMs), animationSpec = tween(ThumbFadeDurationMs),
label = "libraryScrollbarAlpha", label = "libraryScrollbarAlpha",
) )
@@ -224,49 +259,41 @@ private fun LlmpFlexibleScrollbarOverlay(
label = "libraryScrollbarWidth", label = "libraryScrollbarWidth",
) )
BoxWithConstraints(modifier) { if (!canScroll) return
if (!snapshot.canScroll) return@BoxWithConstraints
val trackHeightPx = with(density) { maxHeight.toPx() }.coerceAtLeast(1f) val minThumbPx = with(density) { ThumbMinHeight.toPx() }
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)
Box(modifier) {
// 24dp 触摸热区覆盖轨道;可点击跳转,也可按住拖动。 // 24dp 触摸热区覆盖轨道;可点击跳转,也可按住拖动。
Box( Box(
Modifier Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.width(24.dp) .width(ThumbTouchTargetWidth)
.fillMaxHeight() .fillMaxHeight()
.pointerInput(Unit) { .pointerInput(Unit) {
var gestureId = 0L var gestureId = 0L
fun fractionAt(y: Float): Float { fun fractionAt(y: Float): Float {
val range = (latestTrackHeightPx - latestThumbHeightPx) val trackPx = size.height.toFloat().coerceAtLeast(1f)
.coerceAtLeast(1f) val thumbPx = thumbHeightPx(trackPx, minThumbPx, metrics.value)
return ((y - latestThumbHeightPx / 2f) / range) val range = (trackPx - thumbPx).coerceAtLeast(1f)
.coerceIn(0f, 1f) return ((y - thumbPx / 2f) / range).coerceIn(0f, 1f)
} }
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
gestureId++ gestureId++
dragFraction = fractionAt(down.position.y) dragFraction.floatValue = fractionAt(down.position.y)
isDragging = true isDragging = true
showThumb = true showThumb = true
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
try { try {
scrollRequests.trySend( scrollRequests.trySend(
ScrollbarRequest(dragFraction, gestureId), ScrollbarRequest(dragFraction.floatValue, gestureId),
) )
drag(down.id) { change -> drag(down.id) { change ->
change.consume() change.consume()
dragFraction = fractionAt(change.position.y) dragFraction.floatValue = fractionAt(change.position.y)
scrollRequests.trySend( scrollRequests.trySend(
ScrollbarRequest(dragFraction, gestureId), ScrollbarRequest(dragFraction.floatValue, gestureId),
) )
} }
} finally { } finally {
@@ -279,14 +306,37 @@ private fun LlmpFlexibleScrollbarOverlay(
Box( Box(
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset { IntOffset(0, thumbOffsetPx.roundToInt()) }
.width(thumbWidth) .width(thumbWidth)
.height(thumbHeight)
.alpha(thumbAlpha) .alpha(thumbAlpha)
// 滑块高度在测量阶段算、位置在放置阶段算:滚动时只需重新放置,不重组。
.layout { measurable, constraints ->
val trackPx = constraints.maxHeight.toFloat().coerceAtLeast(1f)
val thumbPx = thumbHeightPx(trackPx, minThumbPx, metrics.value)
val thumbHeight = thumbPx.roundToInt()
val placeable = measurable.measure(
constraints.copy(minHeight = thumbHeight, maxHeight = thumbHeight),
)
layout(placeable.width, constraints.maxHeight) {
val range = (trackPx - thumbPx).coerceAtLeast(0f)
val fraction = if (isDragging) {
dragFraction.floatValue
} else {
progress.value
}
placeable.place(0, (range * fraction).roundToInt())
}
}
.background( .background(
color = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.6f), color = Color.Gray.copy(alpha = 0.6f),
shape = RoundedCornerShape(5.dp), shape = RoundedCornerShape(5.dp),
), ),
) )
} }
} }
private fun thumbHeightPx(
trackPx: Float,
minThumbPx: Float,
metrics: ScrollbarMetrics,
): Float = (trackPx * metrics.visibleFraction)
.coerceIn(minThumbPx.coerceAtMost(trackPx), trackPx)
@@ -1,28 +1,23 @@
package com.zhushenwudi.lovelivemusicplayer.ui.components package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import coil3.compose.AsyncImagePainter.State
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.zhushenwudi.lovelivemusicplayer.AppContainer import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.Const import com.zhushenwudi.lovelivemusicplayer.Const
@@ -30,10 +25,10 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.Artist import com.zhushenwudi.lovelivemusicplayer.domain.Artist
import com.zhushenwudi.lovelivemusicplayer.domain.Music import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.ui.image.CoverImageHolder import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.image.decodeSizeBucketPx import com.zhushenwudi.lovelivemusicplayer.ui.image.decodeSizeBucketPx
import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverImage
import com.zhushenwudi.lovelivemusicplayer.ui.image.sizedMemoryCacheKey import com.zhushenwudi.lovelivemusicplayer.ui.image.sizedMemoryCacheKey
import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey
@@ -82,7 +77,11 @@ fun MusicCoverImage(
} }
/** /**
* @param decodeToDisplaySize 仅网格/列表缩略开启。详情大图勿开。 * @param decodeSize 网格/列表缩略图传显示边长,按它解码省内存;详情大图传 null。
*
* 这里刻意不用 BoxWithConstraints 自己量尺寸:那是一次 SubcomposeLayout,
* pad 横屏一行就有十来个网格单元,每个都要多走一次子组合。
* 调用方本来就知道单元宽度,直接传进来即可。
*/ */
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
@@ -90,35 +89,19 @@ fun AlbumCoverImage(
album: Album, album: Album,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
radius: Dp = 12.dp, radius: Dp = 12.dp,
decodeToDisplaySize: Boolean = false, decodeSize: Dp? = null,
) { ) {
val uri = remember(album.albumId, album.coverPath, album.existFile) { val uri = remember(album.albumId, album.coverPath, album.existFile) {
AppContainer.pathResolver.coverDisplayUri(album) AppContainer.pathResolver.coverDisplayUri(album)
} }
if (!decodeToDisplaySize) { CoverAsyncImage(
CoverAsyncImage( modelUri = uri,
modelUri = uri, stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) }, decodeSize = decodeSize,
decodeSize = null, modifier = modifier,
modifier = modifier, shape = RoundedCornerShape(radius),
shape = RoundedCornerShape(radius), fallbackPadding = 6.dp,
fallbackPadding = 6.dp, )
)
return
}
BoxWithConstraints(modifier) {
val side = remember(maxWidth, maxHeight) {
measuredSquareSide(maxWidth, maxHeight)
}
CoverAsyncImage(
modelUri = uri,
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
decodeSize = side,
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(radius),
fallbackPadding = 6.dp,
)
}
} }
/** /**
@@ -187,16 +170,9 @@ internal fun albumCoverCacheKey(albumId: String): String = "album-cover:$albumId
internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid" internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid"
private fun measuredSquareSide(maxWidth: Dp, maxHeight: Dp): Dp? = /** 播放页/详情大图没给解码尺寸,按这个上限解码,避免整张原图进内存。 */
when { private const val LargeCoverDecodePx = 1024
maxWidth.value.isFinite() && maxHeight.value.isFinite() ->
min(maxWidth, maxHeight).takeIf { it > 0.dp }
maxWidth.value.isFinite() -> maxWidth.takeIf { it > 0.dp }
maxHeight.value.isFinite() -> maxHeight.takeIf { it > 0.dp }
else -> null
}
@OptIn(ExperimentalResourceApi::class)
@Composable @Composable
private fun CoverAsyncImage( private fun CoverAsyncImage(
modelUri: String?, modelUri: String?,
@@ -206,111 +182,80 @@ private fun CoverAsyncImage(
shape: Shape, shape: Shape,
fallbackPadding: Dp, fallbackPadding: Dp,
) { ) {
val fallback = Res.getUri(FALLBACK_LOGO)
val density = LocalDensity.current val density = LocalDensity.current
val clipped = modifier.clip(shape).fillMaxSize() val clipped = modifier.clip(shape).fillMaxSize()
if (modelUri.isNullOrBlank()) {
LlmpAsyncImage(
model = fallback,
contentDescription = null,
contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = clipped.padding(fallbackPadding),
)
return
}
// 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致
// Loading(null) → 空白,即使磁盘/内存仍有封面。
val baseCacheKey = remember(modelUri, stableCacheKey) {
if (
modelUri.startsWith("http://", ignoreCase = true) ||
modelUri.startsWith("https://", ignoreCase = true)
) {
// 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。
modelUri.toImageCacheKey()
} else {
stableCacheKey?.takeIf { it.isNotBlank() } ?: modelUri
}
}
val bucketPx = remember(decodeSize, density) { val bucketPx = remember(decodeSize, density) {
decodeSize?.let { dp -> decodeSize?.let { dp ->
decodeSizeBucketPx(with(density) { dp.roundToPx() }) decodeSizeBucketPx(with(density) { dp.roundToPx() })
} ?: 0 } ?: 0
} }
val cacheKey = remember(baseCacheKey, bucketPx) { val baseCacheKey = remember(modelUri, stableCacheKey) {
sizedMemoryCacheKey(baseCacheKey, bucketPx) when {
} modelUri.isNullOrBlank() -> null
val heldPainter = rememberCoverPainter(cacheKey) // 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。
var showLogoFallback by remember(cacheKey) { mutableStateOf(false) } modelUri.startsWith("http://", ignoreCase = true) ||
var hasSuccess by remember(cacheKey) { modelUri.startsWith("https://", ignoreCase = true) -> modelUri.toImageCacheKey()
mutableStateOf(CoverImageHolder.get(cacheKey) != null) else -> stableCacheKey?.takeIf { it.isNotBlank() } ?: modelUri
}
Box(clipped) {
if (heldPainter != null) {
Image(
painter = heldPainter,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else if (showLogoFallback && !hasSuccess) {
LlmpAsyncImage(
model = fallback,
contentDescription = null,
contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = Modifier
.fillMaxSize()
.padding(fallbackPadding),
)
} }
// key(cacheKey):LazyList 复用不得保留上一首歌的 AsyncImagePainter。 }
// 首次加载空白可接受。同 key 缓存 → heldPainter / placeholderMemoryCacheKey。 val memoryKey = remember(baseCacheKey, bucketPx) {
key(cacheKey) { baseCacheKey?.let { sizedMemoryCacheKey(it, bucketPx) }
LlmpAsyncImage( }
model = modelUri, val cover = rememberCoverImage(
stableCacheKey = baseCacheKey, model = modelUri,
decodeSize = decodeSize, diskCacheKey = baseCacheKey,
contentDescription = null, memoryCacheKey = memoryKey,
contentScale = ContentScale.Crop, decodeSizePx = if (bucketPx > 0) bucketPx else LargeCoverDecodePx,
modifier = Modifier.fillMaxSize(), )
transform = { state ->
when (state) {
is State.Loading -> {
// 仅同 key 底图。绝不复用其他 model 的 painter
// (滚动时曾导致错误封面闪现)。
if (heldPainter != null) State.Loading(heldPainter) else state
}
is State.Error -> { // 不套外层 Box:列表每行都要新建这些节点,占位交给各分支自己的 modifier。
if (heldPainter != null) State.Error( val painter = cover.painter
heldPainter, when {
state.result painter != null -> Image(
) else state painter = painter,
} contentDescription = null,
contentScale = ContentScale.Crop,
else -> state modifier = clipped,
} )
}, // 没有封面可解析,或确定加载失败:落默认 Logo。
onState = { state -> modelUri.isNullOrBlank() || cover.failed -> CoverFallbackLogo(
when (state) { modifier = clipped,
is State.Success -> { bucketPx = bucketPx,
hasSuccess = true padding = fallbackPadding,
showLogoFallback = false )
} // 加载中:画一块底色。留透明的话在深色主题下就是纯黑,
// 滚动时一行行闪黑比占位块难看得多。这里也不放 Logo,
is State.Error -> { // 免得出图瞬间又闪一下。
if (CoverImageHolder.get(cacheKey) == null && !hasSuccess) { else -> Spacer(clipped.background(coverPlaceholderColor()))
showLogoFallback = true
}
}
else -> Unit
}
},
)
}
} }
} }
/** 封面就位前的占位底色,跟列表背景有区分但不抢眼。 */
@Composable
private fun coverPlaceholderColor(): Color =
if (LocalDarkTheme.current) ColorMs.Dark2B333A else ColorMs.GrayEEE
/** 默认 Logo 也走封面缓存,每个尺寸档只解码一次。 */
@OptIn(ExperimentalResourceApi::class)
@Composable
private fun CoverFallbackLogo(modifier: Modifier, bucketPx: Int, padding: Dp) {
val model = remember { Res.getUri(FALLBACK_LOGO) }
val memoryKey = remember(bucketPx) { sizedMemoryCacheKey(FALLBACK_LOGO, bucketPx) }
val logo = rememberCoverImage(
model = model,
diskCacheKey = FALLBACK_LOGO,
memoryCacheKey = memoryKey,
decodeSizePx = if (bucketPx > 0) bucketPx else LargeCoverDecodePx,
)
val painter = logo.painter
if (painter == null) {
Spacer(modifier)
return
}
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.padding(padding),
)
}
@@ -23,10 +23,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -243,10 +241,17 @@ fun NeumorphicSurface(
Box( Box(
modifier = modifier modifier = modifier
.graphicsLayer { // 静止态跳过 graphicsLayer:列表每行两个按钮,白挂图层就是两个多余 RenderNode。
scaleX = scale .then(
scaleY = scale if (scale != 1f) {
} Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
} else {
Modifier
},
)
.then( .then(
if (hasShadow) { if (hasShadow) {
Modifier.drawBehind { Modifier.drawBehind {
@@ -314,20 +319,17 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawSoftRoundRect(
topLeft: Offset, topLeft: Offset,
corner: CornerRadius, corner: CornerRadius,
) { ) {
// 两层外扩模拟柔边。用 drawRoundRect 走 Skia 的 RRect 快路径,
// 不像 Path 那样每次绘制都要分配对象并走通用路径填充。
for (i in 2 downTo 1) { for (i in 2 downTo 1) {
val expand = (2 - i) * 0.55f val expand = (2 - i) * 0.55f
val alphaScale = i / 3f val alphaScale = i / 3f
val path = Path().apply { drawRoundRect(
addRoundRect( color = color.copy(alpha = color.alpha * alphaScale),
RoundRect( topLeft = Offset(topLeft.x - expand, topLeft.y - expand),
left = topLeft.x - expand, size = Size(size.width + expand * 2f, size.height + expand * 2f),
top = topLeft.y - expand, cornerRadius = CornerRadius(corner.x + expand, corner.y + expand),
right = size.width + topLeft.x + expand, style = Fill,
bottom = size.height + topLeft.y + expand, )
cornerRadius = CornerRadius(corner.x + expand, corner.y + expand),
),
)
}
drawPath(path, color = color.copy(alpha = color.alpha * alphaScale), style = Fill)
} }
} }
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.outlined.RadioButtonUnchecked
@@ -33,6 +35,9 @@ import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
/** 正在播放的行左侧竖条。 */
private val PlayingIndicatorWidth = 5.dp
@Composable @Composable
fun SongListItem( fun SongListItem(
music: Music, music: Music,
@@ -57,105 +62,98 @@ fun SongListItem(
} }
val actionColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC val actionColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC
val rowClick: () -> Unit = {
if (selectMode) onCheckedChange?.invoke(!checked) else onClick()
}
// 单层 Row:指示条改成直接绘制,省掉一层 Row 和一个 Box。
// 列表每滑入一行都要新建这些节点,层级越浅,甩动时的组合/测量越便宜。
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(TwoLineListItemHeight), .height(TwoLineListItemHeight)
.drawBehind {
if (isPlaying) {
drawRect(
color = ColorMs.PlayingOrange,
size = Size(PlayingIndicatorWidth.toPx(), size.height),
)
}
}
.padding(start = PlayingIndicatorWidth + 11.dp, end = 11.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( if (selectMode) {
Modifier CircularCheckBox(
.width(5.dp) checked = checked,
.height(TwoLineListItemHeight) onCheckedChange = { onCheckedChange?.invoke(it) },
.background(if (isPlaying) ColorMs.PlayingOrange else Color.Transparent), )
Spacer(Modifier.width(10.dp))
}
MusicCoverImage(
music = music,
size = ListLeadingCoverSize,
radius = 12.dp,
decodeToDisplaySize = true,
modifier = Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = rowClick,
),
) )
Row( Spacer(Modifier.width(10.dp))
Modifier TwoLineListTextColumn(
modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .clickable(
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selectMode) {
CircularCheckBox(
checked = checked,
onCheckedChange = { onCheckedChange?.invoke(it) },
)
Spacer(Modifier.width(10.dp))
}
Box(
Modifier.clickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { onClick = rowClick,
if (selectMode) onCheckedChange?.invoke(!checked) else onClick()
},
), ),
) { title = {
MusicCoverImage( Text(
music = music, text = music.musicName ?: tr("unknown_song"),
size = ListLeadingCoverSize, color = titleColor,
radius = 12.dp, fontSize = ListItemTitleSize,
decodeToDisplaySize = true, fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
lineHeight = ListItemTitleLineHeight,
) )
} },
Spacer(Modifier.width(10.dp)) subtitle = {
TwoLineListTextColumn( Text(
modifier = Modifier text = music.artist.orEmpty(),
.weight(1f) color = artistColor,
.clickable( fontSize = ListItemSubtitleSize,
indication = null, maxLines = 1,
interactionSource = remember { MutableInteractionSource() }, overflow = TextOverflow.Ellipsis,
onClick = { lineHeight = ListItemSubtitleLineHeight,
if (selectMode) onCheckedChange?.invoke(!checked) else onClick() )
}, },
), )
title = { if (!selectMode) {
Text( NeumorphicIconButton(
text = music.musicName ?: tr("unknown_song"), icon = LlmpIcons.AddNext,
color = titleColor, onClick = onPlayNext,
fontSize = ListItemTitleSize, size = LibraryActionButtonSize,
fontWeight = FontWeight.Medium, iconSize = LibraryActionIconSize,
maxLines = 1, radius = LibraryActionButtonRadius,
overflow = TextOverflow.Ellipsis, contentPadding = LibraryActionIconPadding,
lineHeight = ListItemTitleLineHeight, iconColor = actionColor,
) hasShadow = false,
}, modifier = Modifier.padding(horizontal = 3.dp),
subtitle = { )
Text( NeumorphicIconButton(
text = music.artist.orEmpty(), icon = LlmpIcons.More,
color = artistColor, onClick = onMore,
fontSize = ListItemSubtitleSize, size = LibraryActionButtonSize,
maxLines = 1, iconSize = LibraryActionIconSize,
overflow = TextOverflow.Ellipsis, radius = LibraryActionButtonRadius,
lineHeight = ListItemSubtitleLineHeight, contentPadding = LibraryActionIconPadding,
) iconColor = actionColor,
}, hasShadow = false,
modifier = Modifier.padding(horizontal = 3.dp),
) )
if (!selectMode) {
NeumorphicIconButton(
icon = LlmpIcons.AddNext,
onClick = onPlayNext,
size = LibraryActionButtonSize,
iconSize = LibraryActionIconSize,
radius = LibraryActionButtonRadius,
contentPadding = LibraryActionIconPadding,
iconColor = actionColor,
hasShadow = false,
modifier = Modifier.padding(horizontal = 3.dp),
)
NeumorphicIconButton(
icon = LlmpIcons.More,
onClick = onMore,
size = LibraryActionButtonSize,
iconSize = LibraryActionIconSize,
radius = LibraryActionButtonRadius,
contentPadding = LibraryActionIconPadding,
iconColor = actionColor,
hasShadow = false,
modifier = Modifier.padding(horizontal = 3.dp),
)
}
} }
} }
} }
@@ -1,5 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.ui.icons package com.zhushenwudi.lovelivemusicplayer.ui.icons
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -10,13 +12,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
/** /**
* SVG 图标资源,位于 composeResources/drawable。 * SVG 图标资源,位于 composeResources/drawable。
* 使用 Coil3 + coil-svg 渲染,不用 painterResource(Android 不支持)。 * 使用 Coil3 + coil-svg 解码(不用 painterResource,Android 不支持 SVG),
* 解码结果由 [SvgIconStore] 常驻缓存后同步绘制。
*/ */
object LlmpIcons { object LlmpIcons {
val TabMusic = SvgAsset("drawable/tab_tab_music.svg") val TabMusic = SvgAsset("drawable/tab_tab_music.svg")
@@ -46,7 +46,6 @@ object LlmpIcons {
data class SvgAsset(val path: String) data class SvgAsset(val path: String)
@OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun TintIcon( fun TintIcon(
asset: SvgAsset, asset: SvgAsset,
@@ -55,12 +54,12 @@ fun TintIcon(
size: Dp = 18.dp, size: Dp = 18.dp,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
AsyncImage( SvgAssetImage(
model = Res.getUri(asset.path), asset = asset,
modifier = modifier,
size = size,
tint = tint,
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(tint),
contentScale = ContentScale.Fit,
modifier = modifier.size(size),
) )
} }
@@ -80,7 +79,6 @@ fun TintIcon(
) )
} }
@OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun SvgIcon( fun SvgIcon(
asset: SvgAsset, asset: SvgAsset,
@@ -89,11 +87,35 @@ fun SvgIcon(
tint: Color? = null, tint: Color? = null,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
AsyncImage( SvgAssetImage(
model = Res.getUri(asset.path), asset = asset,
modifier = modifier,
size = size,
tint = tint,
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = tint?.let { ColorFilter.tint(it) }, )
}
/** 解码完成前留出等尺寸占位,避免图标就位时整行重新测量。 */
@Composable
private fun SvgAssetImage(
asset: SvgAsset,
modifier: Modifier,
size: Dp,
tint: Color?,
contentDescription: String?,
) {
val sized = modifier.size(size)
val painter = rememberSvgIconPainter(asset, size)
if (painter == null) {
Spacer(sized)
return
}
Image(
painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = modifier.size(size), colorFilter = tint?.let(ColorFilter::tint),
modifier = sized,
) )
} }
@@ -0,0 +1,85 @@
package com.zhushenwudi.lovelivemusicplayer.ui.icons
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil3.Image
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
import coil3.compose.asPainter
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Size
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
/**
* 按「资源路径 + 像素边长」解码一次 SVG 图标后常驻内存。
*
* 图标集固定(约 20 个 × 少数几档尺寸),不做淘汰。
* 列表项因此不必各自挂 AsyncImagePainter:Coil 的尺寸解析会挂起到布局阶段,
* 每个图标都要多走一次协程和一次重组,LazyColumn 快速滚动时这笔开销会累成掉帧。
*/
internal object SvgIconStore {
private val decoded = mutableMapOf<String, Image>()
private val inFlight = mutableSetOf<String>()
/** 新图标解码完成后自增,驱动仍在等待的图标重组。 */
private var revision by mutableIntStateOf(0)
fun key(path: String, sizePx: Int): String = "$path@$sizePx"
/**
* 缓存命中直接返回;未命中时订阅 [revision],解码完成后调用方会重组。
* 必须在组合期间调用才能建立订阅。
*/
fun peek(key: String): Image? {
decoded[key]?.let { return it }
@Suppress("UNUSED_EXPRESSION")
revision
return null
}
@OptIn(ExperimentalResourceApi::class)
suspend fun load(context: PlatformContext, path: String, sizePx: Int) {
val key = key(path, sizePx)
if (decoded.containsKey(key)) return
// 同一图标被整屏列表项同时请求时只解码一次。
if (!inFlight.add(key)) return
val result = runCatching {
SingletonImageLoader.get(context).execute(
ImageRequest.Builder(context)
.data(Res.getUri(path))
.size(Size(sizePx, sizePx))
.build(),
)
}.getOrNull()
inFlight.remove(key)
val image = (result as? SuccessResult)?.image ?: return
decoded[key] = image
revision++
}
}
/** 图标已解码则返回可同步绘制的 Painter,否则返回 null 并触发一次解码。 */
@Composable
internal fun rememberSvgIconPainter(asset: SvgAsset, size: Dp): Painter? {
val context = LocalPlatformContext.current
val sizePx = with(LocalDensity.current) { size.roundToPx() }
val key = SvgIconStore.key(asset.path, sizePx)
val image = if (sizePx > 0) SvgIconStore.peek(key) else null
if (image == null) {
LaunchedEffect(key) {
if (sizePx > 0) SvgIconStore.load(context, asset.path, sizePx)
}
return null
}
return remember(image, context) { image.asPainter(context) }
}
@@ -1,6 +1,8 @@
package com.zhushenwudi.lovelivemusicplayer.ui.image package com.zhushenwudi.lovelivemusicplayer.ui.image
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -18,6 +20,7 @@ import androidx.compose.ui.unit.Dp
import coil3.Image import coil3.Image
import coil3.ImageLoader import coil3.ImageLoader
import coil3.PlatformContext import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Companion.DefaultTransform
@@ -34,12 +37,20 @@ import coil3.network.NetworkRequest
import coil3.network.NetworkResponse import coil3.network.NetworkResponse
import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.Options import coil3.request.Options
import coil3.request.SuccessResult
import coil3.request.crossfade import coil3.request.crossfade
import coil3.size.Size import coil3.size.Size
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
private const val IMG_TAG = "LLMP-Img" private const val IMG_TAG = "LLMP-Img"
@@ -133,50 +144,188 @@ fun clearLlmpImageCache() {
/** /**
* 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。 * 封面解码 [coil3.Image] 的强引用。
* AsyncImage dispose 后持有 [androidx.compose.ui.graphics.painter.Painter] 不安全(RememberObserver.onForgotten)。 * AsyncImage dispose 后持有 [androidx.compose.ui.graphics.painter.Painter] 不安全(RememberObserver.onForgotten);
* 保留 [androidx.compose.ui.graphics.Image] 可在底图/Loading 时重建 Painter,避免空白帧。 * 保留 [coil3.Image] 可随时重建 Painter,列表复用时不会出现空白帧。
*
* 每个 key 一个独立 [MutableState]:组合期间只订阅自己那一项,
* 别的封面入缓存不会让整屏列表项重组。
*
* [order] 限制同时持有的图片数;[entries] 只增不减,但 key 数受曲库规模约束
* (每首歌每个尺寸档一个空容器,量级在几百 KB)。
*/ */
internal object CoverImageHolder { internal object CoverImageHolder {
private const val CAP = 96 /**
private val keys = ArrayDeque<String>() * 按字节限容,不按张数。
private val map = mutableMapOf<String, Image>() *
* 两者差别很大:歌曲列表 48dp 缩略图一张几十 KB,pad 横屏专辑网格一张两三百 KB,
* 播放页大图是 MB 级。按张数限容时,同一个上限在网格里会膨胀成上百兆强引用,
* 而强引用又挡着 Coil 自己的 LRU 回收不掉 —— 直接表现为滚动时的 GC 卡顿。
*/
private const val MAX_BYTES = 24L * 1024 * 1024
fun get(key: String): Image? = map[key] private val order = ArrayDeque<String>()
private val entries = mutableMapOf<String, MutableState<Image?>>()
private var heldBytes = 0L
private fun entry(key: String): MutableState<Image?> =
entries.getOrPut(key) { mutableStateOf(null) }
fun get(key: String): Image? = entries[key]?.value
/** 须在组合期间调用:建立对该 key 的订阅,入缓存后调用方重组。 */
@Composable
fun observe(key: String): Image? = entry(key).value
fun put(key: String, image: Image) { fun put(key: String, image: Image) {
if (map[key] === image) return val state = entry(key)
if (map.put(key, image) == null) { val previous = state.value
keys.addLast(key) if (previous === image) return
} heldBytes -= previous?.size ?: 0L
while (keys.size > CAP) { state.value = image
val old = keys.removeFirst() heldBytes += image.size
map.remove(old) // key 可能重复入队(淘汰过又重新加载),出队时按实际持有量结算,
// 空条目直接跳过,所以重复项不会算错账。
order.addLast(key)
while (heldBytes > MAX_BYTES && order.isNotEmpty()) {
val slot = entries[order.removeFirst()] ?: continue
// 只释放图片,保留 state 容器:组件订阅的是容器本身,
// 换成新容器会让它收不到重新加载的通知,封面就永久空白了。
val released = slot.value ?: continue
heldBytes -= released.size
slot.value = null
} }
} }
fun clear() { fun clear() {
keys.clear() order.clear()
map.clear() heldBytes = 0L
entries.values.forEach { it.value = null }
} }
} }
/** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */ /** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */
internal fun String.toImageCacheKey(): String = replace("'", "%27") internal fun String.toImageCacheKey(): String = replace("'", "%27")
/** [rememberCoverImage] 的结果:painter 就绪即可同步绘制,failed 时由调用方落到 Logo。 */
internal class CoverImageState(
val painter: Painter?,
val failed: Boolean,
)
/**
* 首次加载前的静置时间。
*
* 甩动时一行在视口里待不到这么久,协程会随 item 回收一起取消,于是飞过去的几十行
* 一次磁盘读都不会发起 —— 否则这些解码会把主线程和 IO 占满,fling 直接被拖停。
* 缓存命中的行不走这条路径,回滚时仍然立刻出图。
*/
private const val CoverSettleDelayMs = 80L
/**
* 封面加载:解码尺寸由调用方给定,直接走 [ImageLoader.execute] 并把结果存进
* [CoverImageHolder],命中后以普通 [androidx.compose.foundation.Image] 同步绘制。
*
* 不用 AsyncImage:它的 ConstraintsSizeResolver 要挂起到布局阶段才能拿到尺寸,
* 每个列表项都会多一次协程调度和一次重组;LazyColumn 快速滚动时每秒滑入几十项,
* 这笔固定开销会直接累成掉帧。封面尺寸这里本来就已知,不需要等布局。
*/
@Composable @Composable
internal fun rememberCoverPainter(cacheKey: String?): Painter? { internal fun rememberCoverImage(
if (cacheKey.isNullOrBlank()) return null model: String?,
diskCacheKey: String?,
memoryCacheKey: String?,
decodeSizePx: Int,
): CoverImageState {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val image = CoverImageHolder.get(cacheKey) ?: return null val key = memoryCacheKey?.takeIf { it.isNotBlank() }
return remember(cacheKey, image) { image.asPainter(context) } // 订阅 Holder:异步加载完成后靠它触发重组。
val held = if (key == null) null else CoverImageHolder.observe(key)
// Holder 是 FIFO 淘汰,容量满时会误伤仍在屏上的行;而 Coil 自己的内存缓存
// 通常还留着这张图。组合期间同步取一次,本帧就能画出来 —— 否则回滚到看过的
// 行要等协程跑完才出图,那段空白就是肉眼看到的闪黑。
val cached = held ?: key?.let { peekMemoryCache(context, it) }
var failed by remember(model, key) { mutableStateOf(false) }
if (cached == null && !model.isNullOrBlank()) {
LaunchedEffect(model, key, decodeSizePx) {
delay(CoverSettleDelayMs.milliseconds)
failed = !loadCoverIntoHolder(
context = context,
model = model,
diskCacheKey = diskCacheKey,
memoryCacheKey = key,
decodeSizePx = decodeSizePx,
)
}
}
val painter = remember(cached, context) { cached?.asPainter(context) }
return CoverImageState(painter = painter, failed = failed)
} }
/** 同步读 Coil 内存缓存,不触发任何加载。 */
private fun peekMemoryCache(context: PlatformContext, key: String): Image? =
SingletonImageLoader.get(context).memoryCache?.get(MemoryCache.Key(key))?.image
private suspend fun loadCoverIntoHolder(
context: PlatformContext,
model: String,
diskCacheKey: String?,
memoryCacheKey: String?,
decodeSizePx: Int,
): Boolean {
val loader = SingletonImageLoader.get(context)
suspend fun execute(skipDiskRead: Boolean): ImageResult = loader.execute(
ImageRequest.Builder(context)
.data(model)
.crossfade(false)
.apply {
if (decodeSizePx > 0) size(Size(decodeSizePx, decodeSizePx))
if (skipDiskRead) diskCachePolicy(CachePolicy.WRITE_ONLY)
if (diskCacheKey != null) {
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
diskCacheKey(diskCacheKey)
}
if (memoryCacheKey != null) {
memoryCacheKey(memoryCacheKey)
placeholderMemoryCacheKey(memoryCacheKey)
}
}
.build(),
)
var result = runCatching { execute(skipDiskRead = false) }.getOrNull()
val error = (result as? ErrorResult)?.throwable
if (error != null && error.isUndecodableImage() && diskCacheKey != null) {
// 磁盘字节坏掉:丢弃条目后重拉一次,同一条目内不再循环。
ImageRevalidator.evictUndecodable(diskCacheKey, memoryCacheKey)
result = runCatching { execute(skipDiskRead = true) }.getOrNull()
}
val image = (result as? SuccessResult)?.image
if (image == null) {
AppLog.w(IMG_TAG, "cover load failed url=$model")
return false
}
if (!memoryCacheKey.isNullOrBlank()) {
CoverImageHolder.put(memoryCacheKey, image)
}
return true
}
/**
* 解码并发上限。默认的 [Dispatchers.IO] 不限并发,一屏封面会同时解码:
* 线程竞争和 GC 峰值都会打到主线程上。限到 3 条后改为渐进出图,滚动更稳。
*/
@OptIn(ExperimentalCoroutinesApi::class)
private val coverDecodeDispatcher = Dispatchers.IO.limitedParallelism(3)
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
fun createLlmpImageLoader(context: PlatformContext): ImageLoader { fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
val diskCacheDir = persistentImageCacheDirectory(context) val diskCacheDir = persistentImageCacheDirectory(context)
val loader = ImageLoader.Builder(context) val loader = ImageLoader.Builder(context)
.crossfade(false) .crossfade(false)
.decoderCoroutineContext(coverDecodeDispatcher)
.fetcherCoroutineContext(coverDecodeDispatcher)
.memoryCache { .memoryCache {
MemoryCache.Builder() MemoryCache.Builder()
// 老机内存紧:略降占比,配合按显示尺寸解码。 // 老机内存紧:略降占比,配合按显示尺寸解码。
@@ -215,6 +364,9 @@ internal fun decodeSizeBucketPx(px: Int): Int = when {
px <= 96 -> 96 px <= 96 -> 96
px <= 144 -> 144 px <= 144 -> 144
px <= 256 -> 256 px <= 256 -> 256
// 高密度屏上的网格单元常落在 260~320px,少了这一档会被抬到 384,
// 单张就从 327KB 涨到 590KB。
px <= 320 -> 320
px <= 384 -> 384 px <= 384 -> 384
else -> 512 else -> 512
} }
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -467,6 +468,23 @@ fun FullPlayerScreen(
} }
} }
/** 横屏左栏整体右移一些,不要贴着屏幕边。 */
private val LandscapeBodyStartPadding = 48.dp
/** 横屏封面上限。平板上可用高度能到 280dp 以上,卡住它免得封面占满整栏。 */
private val LandscapeCoverMaxSize = 250.dp
/** 封面区再内缩一点,连带下方同宽的内容一起偏右。 */
private val LandscapeCoverInsetStart = 24.dp
/**
* 封面边长未知时先撑满;已知后与封面同宽,左右边缘才对得上。
* 偏移量要和封面那边保持一致,否则两者又错开。
*/
private fun Modifier.coverAlignedWidth(side: Dp?): Modifier =
offset(x = LandscapeCoverInsetStart)
.then(if (side != null) width(side) else fillMaxWidth())
@Composable @Composable
private fun LandscapePlayerBody( private fun LandscapePlayerBody(
current: Music?, current: Music?,
@@ -499,12 +517,15 @@ private fun LandscapePlayerBody(
.statusBarsPadding() .statusBarsPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding( .padding(
start = safeStart + 28.dp, start = safeStart + LandscapeBodyStartPadding,
end = safeEnd + 28.dp, end = safeEnd + 28.dp,
top = 12.dp, top = 12.dp,
bottom = 16.dp, bottom = 16.dp,
), ),
) { ) {
// 封面是受高度约束的正方形,通常比左栏窄;下方内容跟随它的实际边长,
// 否则 fillMaxWidth 会让两者左右边缘差出一截。
var coverSide by remember { mutableStateOf<Dp?>(null) }
Column( Column(
Modifier Modifier
.weight(0.42f) .weight(0.42f)
@@ -513,6 +534,7 @@ private fun LandscapePlayerBody(
.padding(end = 24.dp), .padding(end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// 收起按钮保持贴左栏边缘,不跟封面对齐。
Row( Row(
Modifier.fillMaxWidth().height(40.dp), Modifier.fillMaxWidth().height(40.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -529,19 +551,22 @@ private fun LandscapePlayerBody(
contentDescription = tr("collapse"), contentDescription = tr("collapse"),
) )
} }
Box( // 直接用 BoxWithConstraints 承担居中:套在 Box 里再 fillMaxSize 的话,
// 外层的 contentAlignment 不起作用,封面会左上角对齐。
BoxWithConstraints(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth()
.offset(x = LandscapeCoverInsetStart),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
BoxWithConstraints(Modifier.fillMaxSize()) { val side = maxWidth
val side = maxWidth .coerceAtMost(maxHeight)
.coerceAtMost(maxHeight) .coerceAtMost(LandscapeCoverMaxSize)
.coerceAtMost(300.dp) .coerceAtLeast(1.dp)
.coerceAtLeast(1.dp) // 下方内容宽度不影响它们的高度,所以这次写入会立刻收敛。
PlayerCoverArt(music = current, size = side) if (coverSide != side) coverSide = side
} PlayerCoverArt(music = current, size = side)
} }
Text( Text(
text = current?.musicName ?: tr("not_playing"), text = current?.musicName ?: tr("not_playing"),
@@ -553,9 +578,7 @@ private fun LandscapePlayerBody(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
), ),
modifier = Modifier modifier = Modifier.coverAlignedWidth(coverSide),
.fillMaxWidth()
.padding(horizontal = 12.dp),
) )
val artist = current?.artist.orEmpty() val artist = current?.artist.orEmpty()
if (artist.isNotBlank()) { if (artist.isNotBlank()) {
@@ -569,8 +592,8 @@ private fun LandscapePlayerBody(
fontSize = 12.sp, fontSize = 12.sp,
), ),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .coverAlignedWidth(coverSide)
.padding(horizontal = 12.dp, vertical = 2.dp), .padding(vertical = 2.dp),
) )
} }
PlayerFuncButtons( PlayerFuncButtons(
@@ -582,6 +605,8 @@ private fun LandscapePlayerBody(
onToggleLove = onToggleLove, onToggleLove = onToggleLove,
onAddSong = onAddSong, onAddSong = onAddSong,
onToggleTranslate = onToggleTranslate, onToggleTranslate = onToggleTranslate,
modifier = Modifier.coverAlignedWidth(coverSide),
horizontalPadding = 0.dp,
) )
PlayerSeekBar( PlayerSeekBar(
thumbColor = thumbColor, thumbColor = thumbColor,
@@ -589,12 +614,14 @@ private fun LandscapePlayerBody(
inactiveTrack = inactiveTrack, inactiveTrack = inactiveTrack,
timeColor = timeColor, timeColor = timeColor,
onSeek = { AppContainer.player.seekTo(it) }, onSeek = { AppContainer.player.seekTo(it) },
modifier = Modifier.coverAlignedWidth(coverSide),
trackInset = 0.dp,
labelInset = 0.dp,
) )
Row( Row(
Modifier Modifier
.fillMaxWidth() .coverAlignedWidth(coverSide)
.height(64.dp) .height(64.dp),
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -1174,13 +1201,15 @@ private fun PlayerFuncButtons(
onAddSong: () -> Unit, onAddSong: () -> Unit,
onToggleTranslate: () -> Unit, onToggleTranslate: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** 横屏左栏要和控制键左右对齐,传 0。 */
horizontalPadding: Dp = 16.dp,
) { ) {
val btnBg = Color.Transparent val btnBg = Color.Transparent
val btnIcon = iconTint val btnIcon = iconTint
Row( Row(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = horizontalPadding),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -1287,6 +1316,11 @@ private fun PlayerSeekBar(
inactiveTrack: Color, inactiveTrack: Color,
timeColor: Color, timeColor: Color,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
modifier: Modifier = Modifier,
/** 轨道两端留白。横屏左栏要和控制键对齐,传 0。 */
trackInset: Dp = 24.dp,
/** 时间文字的左右内边距。 */
labelInset: Dp = 16.dp,
) { ) {
val positionMs = rememberPlayerField { it.positionMs } val positionMs = rememberPlayerField { it.positionMs }
val durationMs = rememberPlayerField { it.durationMs } val durationMs = rememberPlayerField { it.durationMs }
@@ -1296,14 +1330,18 @@ private fun PlayerSeekBar(
var dragValue by remember { mutableFloatStateOf(0f) } var dragValue by remember { mutableFloatStateOf(0f) }
val display = if (dragging) dragValue else positionMs.toFloat().coerceIn(0f, max) val display = if (dragging) dragValue else positionMs.toFloat().coerceIn(0f, max)
val fraction = (display / max).coerceIn(0f, 1f) val fraction = (display / max).coerceIn(0f, 1f)
val thumbRadius = 10.dp
// 滑块中心至少离边一个半径,否则贴边时会被画到容器外;
// 轨道本身可以贴边,两者的留白因此要分开算。
val thumbInset = maxOf(trackInset, thumbRadius)
Column(Modifier.fillMaxWidth()) { Column(modifier.fillMaxWidth()) {
BoxWithConstraints( BoxWithConstraints(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(48.dp)
.pointerInput(max) { .pointerInput(max, thumbInset) {
val inset = 24.dp.toPx() val inset = thumbInset.toPx()
fun xToValue(x: Float): Float { fun xToValue(x: Float): Float {
val usable = (size.width - inset * 2).coerceAtLeast(1f) val usable = (size.width - inset * 2).coerceAtLeast(1f)
return ((x - inset) / usable).coerceIn(0f, 1f) * max return ((x - inset) / usable).coerceIn(0f, 1f) * max
@@ -1321,11 +1359,9 @@ private fun PlayerSeekBar(
} }
}, },
) { ) {
val thumbRadius = 10.dp
val overlayPad = 24.dp
val trackHeight = 4.dp val trackHeight = 4.dp
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
val inset = overlayPad.toPx() val inset = trackInset.toPx()
val y = size.height / 2f val y = size.height / 2f
val trackW = (size.width - inset * 2f).coerceAtLeast(0f) val trackW = (size.width - inset * 2f).coerceAtLeast(0f)
val top = y - trackHeight.toPx() / 2f val top = y - trackHeight.toPx() / 2f
@@ -1348,7 +1384,7 @@ private fun PlayerSeekBar(
Box( Box(
Modifier Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.offset(x = overlayPad - thumbRadius + (maxWidth - overlayPad * 2) * fraction) .offset(x = thumbInset - thumbRadius + (maxWidth - thumbInset * 2) * fraction)
.size(thumbRadius * 2) .size(thumbRadius * 2)
.shadow(1.dp, CircleShape) .shadow(1.dp, CircleShape)
.background(thumbColor, CircleShape), .background(thumbColor, CircleShape),
@@ -1357,7 +1393,7 @@ private fun PlayerSeekBar(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = labelInset),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(formatMs(display.roundToLong()), color = timeColor, fontSize = 12.sp) Text(formatMs(display.roundToLong()), color = timeColor, fontSize = 12.sp)
@@ -3,6 +3,7 @@ package com.zhushenwudi.lovelivemusicplayer.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -37,6 +38,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -93,7 +97,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.addMusicPlayNext
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalUnscaledDensity
@Composable @Composable
fun LibraryTabContent( fun LibraryTabContent(
tab: LibraryTab, tab: LibraryTab,
@@ -258,39 +262,107 @@ private fun AlbumList(
return return
} }
val listBottomInset = LocalHomeListBottomInset.current val listBottomInset = LocalHomeListBottomInset.current
val landscape = LocalIsLandscape.current
val largeScreen = isLargeScreenDevice()
LlmpFlexibleScrollbar( LlmpFlexibleScrollbar(
state = resolvedGridState, state = resolvedGridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
LazyVerticalGrid( // 整个网格量一次宽度,算出单元宽度后传给所有单元决定封面解码尺寸。
columns = if (LocalIsLandscape.current) { // 以前是每个单元自己套 BoxWithConstraints 量,pad 横屏一行十来个单元
GridCells.Adaptive(108.dp) // 就是十来次子组合。
} else { BoxWithConstraints(Modifier.fillMaxSize()) {
GridCells.Fixed(3) val density = LocalDensity.current
}, val availablePx = constraints.maxWidth
state = resolvedGridState, val columns = remember(availablePx, landscape, largeScreen, density) {
modifier = Modifier.fillMaxSize(), albumGridColumns(availablePx, landscape, largeScreen, density)
contentPadding = PaddingValues( }
start = 16.dp, val cellWidth = remember(availablePx, columns, density) {
end = 16.dp, albumGridCellWidth(availablePx, columns, density)
bottom = listBottomInset, }
), LazyVerticalGrid(
horizontalArrangement = Arrangement.spacedBy(10.dp), columns = GridCells.Fixed(columns),
) { state = resolvedGridState,
items( modifier = Modifier.fillMaxSize(),
items = albums, contentPadding = PaddingValues(
key = { it.albumId ?: "${it.albumName}-${it.coverPath}" }, start = AlbumGridHorizontalPadding,
contentType = { "album" }, end = AlbumGridHorizontalPadding,
) { album -> bottom = listBottomInset,
AlbumGridItem( ),
album = album, horizontalArrangement = Arrangement.spacedBy(AlbumGridSpacing),
onClick = { album.albumId?.let(onOpen) }, ) {
) items(
items = albums,
key = { it.albumId ?: "${it.albumName}-${it.coverPath}" },
contentType = { "album" },
) { album ->
AlbumGridItem(
album = album,
onClick = { album.albumId?.let(onOpen) },
cellWidth = cellWidth,
)
}
} }
} }
} }
} }
private val AlbumGridHorizontalPadding = 16.dp
private val AlbumGridSpacing = 10.dp
private val AlbumGridMinCellWidth = 108.dp
/**
* 大屏(平板、展开的折叠屏)竖屏 4 列、横屏 6 列;手机竖屏 3 列。
*
* 手机横屏理论上走不到 —— 原生侧只给大屏放开旋转(见 ScreenOrientationPolicy),
* 这里仍按最小单元宽兜底,避免真遇上时单元被拉成一整屏宽。
* 兜底公式照搬 GridCells.Adaptive,连取整方式也一致。
*/
private fun albumGridColumns(
gridWidthPx: Int,
landscape: Boolean,
largeScreen: Boolean,
density: Density,
): Int {
if (largeScreen) return if (landscape) 6 else 4
if (!landscape) return 3
with(density) {
val available = gridWidthPx - AlbumGridHorizontalPadding.roundToPx() * 2
if (available <= 0) return 3
val spacing = AlbumGridSpacing.roundToPx()
val minSize = AlbumGridMinCellWidth.roundToPx()
return ((available + spacing) / (minSize + spacing)).coerceAtLeast(1)
}
}
/** 与原生旋转策略同一个判据,见 ScreenOrientationPolicy 的 600dp 阈值。 */
private val LargeScreenMinSmallestWidth = 600.dp
/**
* 最小边是否达到大屏阈值。
*
* 必须用未缩放的设备密度:ProvideDesignDensity 会按设计稿把密度放大到 1.35 倍,
* 拿缩放后的 dp 去比,800dp 的平板会算成 593dp 被误判成手机。
*/
@Composable
private fun isLargeScreenDevice(): Boolean {
val container = LocalWindowInfo.current.containerSize
if (container.width <= 0 || container.height <= 0) return false
val density = LocalUnscaledDensity.current ?: LocalDensity.current
return with(density) {
minOf(container.width, container.height).toDp() >= LargeScreenMinSmallestWidth
}
}
/** 只用来选封面解码档位,差几像素不影响结果。 */
private fun albumGridCellWidth(gridWidthPx: Int, columns: Int, density: Density): Dp {
with(density) {
val available = gridWidthPx - AlbumGridHorizontalPadding.roundToPx() * 2 -
AlbumGridSpacing.roundToPx() * (columns - 1)
return (available / columns).coerceAtLeast(0).toDp()
}
}
@Composable @Composable
private fun ArtistList( private fun ArtistList(
artists: List<Artist>, artists: List<Artist>,
@@ -66,12 +66,15 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize()) Box(Modifier.fillMaxSize())
return@CompositionLocalProvider return@CompositionLocalProvider
} }
val heightDp = heightPx / base.density
val widthPx = containerPx.width val widthPx = containerPx.width
val isLandscape = widthPx > 0 && widthPx > heightPx val isLandscape = widthPx > 0 && widthPx > heightPx
// 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 // 按长边比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。
// 横屏高度变矮时下限仍是 1,不会按变矮的高度放大或缩小。 //
val scale = (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) // 取长边而不是当前窗口高度,是为了让横竖屏共用同一个 dp 坐标系。
// 按窗口高度算的话,横屏高度变成短边,scale 会从上限 1.35 掉回 1,
// 同一个 dp 在横屏下物理尺寸要小四分之一。
val longestDp = maxOf(widthPx, heightPx) / base.density
val scale = (longestDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f)
val scaled = remember(base.density, scale) { val scaled = remember(base.density, scale) {
Density( Density(
density = base.density * scale, density = base.density * scale,
+51 -48
View File
@@ -13,19 +13,19 @@
5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; }; 5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; };
76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */; }; 76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */; };
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; }; 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; };
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; };
C8D9E0F1A2B345678901BCDE /* WebViewContentBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */; };
A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; }; 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; };
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; }; 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; };
A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; }; A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; };
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; };
C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; }; C8D9E0F1A2B345678901BCDE /* WebViewContentBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */; };
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; };
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */; };
D1E2F3A4B5C60718293A4B02 /* QqShareHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */; }; D1E2F3A4B5C60718293A4B02 /* QqShareHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */; };
D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */; }; D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */; };
D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */; }; D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */; };
F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; };
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */; };
I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */; }; I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -55,8 +55,6 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0142951DE661E55DC3207327 /* WidgetAppUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAppUtils.swift; sourceTree = "<group>"; }; 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAppUtils.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = "<group>"; };
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = "<group>"; };
1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = "<group>"; }; 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = "<group>"; };
30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = "<group>"; }; 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = "<group>"; };
@@ -66,20 +64,33 @@
86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = "<group>"; };
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = "<group>"; };
B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = "<group>"; }; B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = "<group>"; };
CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = "<group>"; }; D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = "<group>"; };
D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TencentOpenAPI.framework; path = Vendor/TencentOpenAPI.framework; sourceTree = "<group>"; }; D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TencentOpenAPI.framework; path = Vendor/TencentOpenAPI.framework; sourceTree = "<group>"; };
D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = TencentOpenApi_IOS_Bundle.bundle; path = Vendor/TencentOpenApi_IOS_Bundle.bundle; sourceTree = "<group>"; }; D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = TencentOpenApi_IOS_Bundle.bundle; path = Vendor/TencentOpenApi_IOS_Bundle.bundle; sourceTree = "<group>"; };
I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "iosApp-Bridging-Header.h"; sourceTree = "<group>"; };
E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
I18N0PLIST0FILE0REF0DE01 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
I18N0PLIST0FILE0REF0EN01 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; I18N0PLIST0FILE0REF0EN01 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
I18N0PLIST0FILE0REF0JA01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; }; I18N0PLIST0FILE0REF0JA01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
I18N0PLIST0FILE0REF0DE01 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; }; I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "iosApp-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
D1E2F3A4B5C60718293A4B08 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0ECDA917A71DFD183A635257 /* CarPlay */ = { 0ECDA917A71DFD183A635257 /* CarPlay */ = {
isa = PBXGroup; isa = PBXGroup;
@@ -119,15 +130,6 @@
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D1E2F3A4B5C60718293A4B09 /* Vendor */ = {
isa = PBXGroup;
children = (
D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */,
D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */,
);
name = Vendor;
sourceTree = "<group>";
};
5B4BD3733F8124940306FE19 /* pip */ = { 5B4BD3733F8124940306FE19 /* pip */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -156,6 +158,15 @@
path = WidgetExtension; path = WidgetExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D1E2F3A4B5C60718293A4B09 /* Vendor */ = {
isa = PBXGroup;
children = (
D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */,
D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */,
);
name = Vendor;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -261,14 +272,6 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D1E2F3A4B5C60718293A4B08 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
@@ -289,7 +292,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "set -euo pipefail\ncd \"$SRCROOT/..\"\nif [ \"YES\" = \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}\" ]; then\n echo \"Kotlin framework was built by the IDE; syncing Compose resources only\"\n ./gradlew -Pcompose.ios.resources.archs=arm64 :composeApp:syncComposeResourcesForIos --rerun\n exit 0\nfi\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode"; shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nset -euo pipefail\ncd \"$SRCROOT/..\"\nif [ \"YES\" = \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}\" ]; then\n echo \"Kotlin framework was built by the IDE; syncing Compose resources only\"\n ./gradlew -Pcompose.ios.resources.archs=arm64 :composeApp:syncComposeResourcesForIos --rerun\n exit 0\nfi\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode";
}; };
C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */ = { C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@@ -312,20 +315,6 @@
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXVariantGroup section */
I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */,
I18N0PLIST0FILE0REF0EN01 /* en */,
I18N0PLIST0FILE0REF0JA01 /* ja */,
I18N0PLIST0FILE0REF0DE01 /* de */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
297B59D9CE634556D6007C40 /* Sources */ = { 297B59D9CE634556D6007C40 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -365,6 +354,20 @@
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */,
I18N0PLIST0FILE0REF0EN01 /* en */,
I18N0PLIST0FILE0REF0JA01 /* ja */,
I18N0PLIST0FILE0REF0DE01 /* de */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
11814011E7D6EC822BFBC66D /* Release */ = { 11814011E7D6EC822BFBC66D /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -475,12 +478,12 @@
WebKit, WebKit,
"-lsqlite3", "-lsqlite3",
); );
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer;
PRODUCT_NAME = LLMP; PRODUCT_NAME = LLMP;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-AD-HOC"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-AD-HOC";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Profile; name = Profile;
@@ -728,12 +731,12 @@
WebKit, WebKit,
"-lsqlite3", "-lsqlite3",
); );
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer;
PRODUCT_NAME = LLMP; PRODUCT_NAME = LLMP;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-DEVELOP"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-DEVELOP";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
@@ -777,12 +780,12 @@
WebKit, WebKit,
"-lsqlite3", "-lsqlite3",
); );
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer;
PRODUCT_NAME = LLMP; PRODUCT_NAME = LLMP;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-RELEASE"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-RELEASE";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
@@ -1,12 +1,55 @@
package com.zhushenwudi.lovelivemusicplayer.lyric package com.zhushenwudi.lovelivemusicplayer.lyric
import kotlinx.cinterop.BooleanVar
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import platform.Foundation.NSBundle import platform.Foundation.NSBundle
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
/**
* 解锁目录名。Info.plist 开了 UIFileSharingEnabled,
* 用户可在「文件」App 的本应用目录下自行新建它。
*/
private const val UNLOCK_DIR_NAME = "LLMP"
actual object PlatformInfo { actual object PlatformInfo {
// iOS 在 EULA 未接受前可能限制日文/罗马音歌词。 /**
actual val allowEulaLyric: Boolean = true * iOS 默认只提供中文歌词;Documents 下存在 LLMP 目录才解锁日文/罗马音。
*
* 刻意只在首次读取时查一次盘:[LyricService.lrcType] 的初值、歌词下载分支、
* 以及多处 UI 分支都依赖这个值,运行中途翻转会让它们对不上
* (例如 lrcType 停在 0 要显示日文,而日文根本没下载过)。
* 用户新建目录后重启应用生效。
*/
actual val allowEulaLyric: Boolean by lazy { unlockDirExists() }
actual val isIos: Boolean = true actual val isIos: Boolean = true
actual val appVersion: String = actual val appVersion: String =
NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String
?: "" ?: ""
} }
@OptIn(ExperimentalForeignApi::class)
private fun unlockDirExists(): Boolean {
val docs = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
NSUserDomainMask,
true,
).firstOrNull() as? String ?: return false
val path = if (docs.endsWith("/")) {
"$docs$UNLOCK_DIR_NAME"
} else {
"$docs/$UNLOCK_DIR_NAME"
}
return memScoped {
val isDir = alloc<BooleanVar>()
// 必须是目录:同名文件不算解锁。
NSFileManager.defaultManager.fileExistsAtPath(path, isDirectory = isDir.ptr) &&
isDir.value
}
}