Compare commits
2
Commits
34842e2577
...
53897bda1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53897bda1a | ||
|
|
52ab485e9c |
+12
-2
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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
|
||||||
|
|||||||
+109
-59
@@ -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)
|
||||||
|
|||||||
+88
-143
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+20
-18
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-87
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-15
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -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) }
|
||||||
|
}
|
||||||
+172
-20
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-27
@@ -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)
|
||||||
|
|||||||
+97
-25
@@ -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>,
|
||||||
|
|||||||
+7
-4
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+45
-2
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user