optimize picture show
This commit is contained in:
+1
@@ -52,6 +52,7 @@ fun AlbumGridItem(
|
||||
AlbumCoverImage(
|
||||
album = album,
|
||||
radius = 12.dp,
|
||||
decodeToDisplaySize = true,
|
||||
modifier = Modifier.fillMaxSize().then(heroMod),
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ fun ArtistListItem(
|
||||
ArtistPhotoImage(
|
||||
artist = artist,
|
||||
size = ListLeadingCoverSize,
|
||||
decodeToDisplaySize = true,
|
||||
modifier = Modifier.fillMaxSize().then(heroMod),
|
||||
)
|
||||
}
|
||||
|
||||
+6
-1
@@ -255,7 +255,12 @@ private fun AddSheetMenuRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (coverMusic != null) {
|
||||
MusicCoverImage(music = coverMusic, size = 48.dp, radius = 12.dp)
|
||||
MusicCoverImage(
|
||||
music = coverMusic,
|
||||
size = 48.dp,
|
||||
radius = 12.dp,
|
||||
decodeToDisplaySize = true,
|
||||
)
|
||||
} else {
|
||||
CoverPlaceholder(label = menu.name.take(1), size = 48.dp, radius = 12.dp)
|
||||
}
|
||||
|
||||
+73
-12
@@ -2,6 +2,7 @@ package com.zhushenwudi.lovelivemusicplayer.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -17,8 +18,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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 com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||
@@ -29,7 +32,9 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
||||
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.CoverImageHolder
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.decodeSizeBucketPx
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.sizedMemoryCacheKey
|
||||
import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey
|
||||
|
||||
private const val FALLBACK_LOGO = "drawable/logo_svg_logo.svg"
|
||||
@@ -49,7 +54,12 @@ fun rememberMusicCoverLocalPath(music: Music?): String? =
|
||||
?.takeIf { path -> AppContainer.fileSystem.exists(path) }
|
||||
}
|
||||
|
||||
/** 封面可解析且可加载时显示,否则显示默认 Logo。 */
|
||||
/**
|
||||
* 封面可解析且可加载时显示,否则显示默认 Logo。
|
||||
*
|
||||
* @param decodeToDisplaySize 仅列表缩略图开启:按 [size] 解码以省内存/CPU。
|
||||
* 播放页/大图保持默认 false。
|
||||
*/
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
@Composable
|
||||
fun MusicCoverImage(
|
||||
@@ -57,11 +67,13 @@ fun MusicCoverImage(
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 48.dp,
|
||||
radius: Dp = 12.dp,
|
||||
decodeToDisplaySize: Boolean = false,
|
||||
) {
|
||||
val uri = rememberMusicCoverUri(music)
|
||||
CoverAsyncImage(
|
||||
modelUri = uri,
|
||||
stableCacheKey = music.musicId?.let { musicCoverCacheKey(it) },
|
||||
decodeSize = size.takeIf { decodeToDisplaySize },
|
||||
// 先设置尺寸,再应用调用方修饰符(如 sharedElement 在 size 之后)。
|
||||
modifier = Modifier.size(size).then(modifier),
|
||||
shape = RoundedCornerShape(radius),
|
||||
@@ -69,28 +81,51 @@ fun MusicCoverImage(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param decodeToDisplaySize 仅网格/列表缩略开启。详情大图勿开。
|
||||
*/
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
@Composable
|
||||
fun AlbumCoverImage(
|
||||
album: Album,
|
||||
modifier: Modifier = Modifier,
|
||||
radius: Dp = 12.dp,
|
||||
decodeToDisplaySize: Boolean = false,
|
||||
) {
|
||||
val uri = remember(album.albumId, album.coverPath, album.existFile) {
|
||||
AppContainer.pathResolver.coverDisplayUri(album)
|
||||
}
|
||||
CoverAsyncImage(
|
||||
modelUri = uri,
|
||||
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(radius),
|
||||
fallbackPadding = 6.dp,
|
||||
)
|
||||
if (!decodeToDisplaySize) {
|
||||
CoverAsyncImage(
|
||||
modelUri = uri,
|
||||
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
|
||||
decodeSize = null,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(radius),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 艺人头像:48×48 圆形封面。
|
||||
* 加载 https `{R2}/LLMP-M/artist_webp/{uid}.webp`(经 Coil 网络请求)。
|
||||
*
|
||||
* @param decodeToDisplaySize 仅列表缩略开启。
|
||||
*/
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
@Composable
|
||||
@@ -98,6 +133,7 @@ fun ArtistPhotoImage(
|
||||
artist: Artist,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 48.dp,
|
||||
decodeToDisplaySize: Boolean = false,
|
||||
) {
|
||||
val uri = remember(artist.uid, artist.photo) {
|
||||
artistPhotoDisplayUri(artist)
|
||||
@@ -105,6 +141,7 @@ fun ArtistPhotoImage(
|
||||
CoverAsyncImage(
|
||||
modelUri = uri,
|
||||
stableCacheKey = artistPhotoCacheKey(artist.uid),
|
||||
decodeSize = size.takeIf { decodeToDisplaySize },
|
||||
modifier = Modifier.size(size).then(modifier),
|
||||
shape = CircleShape,
|
||||
fallbackPadding = 6.dp,
|
||||
@@ -120,8 +157,9 @@ private fun artistPhotoDisplayUri(artist: Artist): String? {
|
||||
return "${Const.R2_OSS_URL}LLMP-M/artist_webp/${artist.uid}.webp"
|
||||
}
|
||||
|
||||
/** WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。
|
||||
* `circular` 为 true 时圆形封面,否则圆角矩形。
|
||||
/**
|
||||
* WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。
|
||||
* 不做按显示尺寸解码(播放页需清晰大图)。
|
||||
*/
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
@Composable
|
||||
@@ -136,6 +174,7 @@ fun PlayerMusicCover(
|
||||
CoverAsyncImage(
|
||||
modelUri = uri,
|
||||
stableCacheKey = music?.musicId?.let { musicCoverCacheKey(it) },
|
||||
decodeSize = null,
|
||||
modifier = modifier,
|
||||
shape = if (circular) CircleShape else RoundedCornerShape(radius),
|
||||
fallbackPadding = fallbackPadding,
|
||||
@@ -148,22 +187,34 @@ internal fun albumCoverCacheKey(albumId: String): String = "album-cover:$albumId
|
||||
|
||||
internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid"
|
||||
|
||||
private fun measuredSquareSide(maxWidth: Dp, maxHeight: Dp): Dp? =
|
||||
when {
|
||||
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
|
||||
private fun CoverAsyncImage(
|
||||
modelUri: String?,
|
||||
stableCacheKey: String? = null,
|
||||
decodeSize: Dp? = null,
|
||||
modifier: Modifier,
|
||||
shape: Shape,
|
||||
fallbackPadding: Dp,
|
||||
) {
|
||||
val fallback = Res.getUri(FALLBACK_LOGO)
|
||||
val density = LocalDensity.current
|
||||
val clipped = modifier.clip(shape).fillMaxSize()
|
||||
if (modelUri.isNullOrBlank()) {
|
||||
LlmpAsyncImage(
|
||||
model = fallback,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
decodeSize = decodeSize,
|
||||
modifier = clipped.padding(fallbackPadding),
|
||||
)
|
||||
return
|
||||
@@ -171,7 +222,7 @@ private fun CoverAsyncImage(
|
||||
|
||||
// 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致
|
||||
// Loading(null) → 空白,即使磁盘/内存仍有封面。
|
||||
val cacheKey = remember(modelUri, stableCacheKey) {
|
||||
val baseCacheKey = remember(modelUri, stableCacheKey) {
|
||||
stableCacheKey?.takeIf { it.isNotBlank() }
|
||||
?: if (modelUri.startsWith("http", ignoreCase = true)) {
|
||||
modelUri.toImageCacheKey()
|
||||
@@ -179,6 +230,14 @@ private fun CoverAsyncImage(
|
||||
modelUri
|
||||
}
|
||||
}
|
||||
val bucketPx = remember(decodeSize, density) {
|
||||
decodeSize?.let { dp ->
|
||||
decodeSizeBucketPx(with(density) { dp.roundToPx() })
|
||||
} ?: 0
|
||||
}
|
||||
val cacheKey = remember(baseCacheKey, bucketPx) {
|
||||
sizedMemoryCacheKey(baseCacheKey, bucketPx)
|
||||
}
|
||||
val heldPainter = rememberCoverPainter(cacheKey)
|
||||
var showLogoFallback by remember(cacheKey) { mutableStateOf(false) }
|
||||
var hasSuccess by remember(cacheKey) {
|
||||
@@ -198,6 +257,7 @@ private fun CoverAsyncImage(
|
||||
model = fallback,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
decodeSize = decodeSize,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(fallbackPadding),
|
||||
@@ -208,7 +268,8 @@ private fun CoverAsyncImage(
|
||||
key(cacheKey) {
|
||||
LlmpAsyncImage(
|
||||
model = modelUri,
|
||||
stableCacheKey = cacheKey,
|
||||
stableCacheKey = baseCacheKey,
|
||||
decodeSize = decodeSize,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
+6
-1
@@ -92,7 +92,12 @@ fun SongListItem(
|
||||
},
|
||||
),
|
||||
) {
|
||||
MusicCoverImage(music = music, size = ListLeadingCoverSize, radius = 12.dp)
|
||||
MusicCoverImage(
|
||||
music = music,
|
||||
size = ListLeadingCoverSize,
|
||||
radius = 12.dp,
|
||||
decodeToDisplaySize = true,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
TwoLineListTextColumn(
|
||||
|
||||
+57
-11
@@ -10,6 +10,8 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
@@ -31,6 +33,7 @@ import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Options
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Size
|
||||
import coil3.svg.SvgDecoder
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -137,8 +140,9 @@ internal object ImageRevalidator {
|
||||
.networkCachePolicy(CachePolicy.ENABLED)
|
||||
.crossfade(false)
|
||||
.listener(
|
||||
onSuccess = { _, result ->
|
||||
CoverImageHolder.put(cacheKey, result.image)
|
||||
onSuccess = { _, _ ->
|
||||
// 只刷新 Coil 磁盘/内存;不写入 CoverImageHolder,
|
||||
// 避免无尺寸的原图覆盖列表缩略图。
|
||||
complete(cacheKey)
|
||||
},
|
||||
onError = { _, result ->
|
||||
@@ -208,7 +212,8 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
|
||||
.crossfade(false)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.35)
|
||||
// 老机内存紧:略降占比,配合按显示尺寸解码。
|
||||
.maxSizePercent(context, 0.20)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
@@ -230,6 +235,23 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
|
||||
return loader
|
||||
}
|
||||
|
||||
/**
|
||||
* 将显示边长收成缓存桶,减少 47/48/49dp 之类碎片键,
|
||||
* 同时保证列表小图与播放页大图不会互相覆盖。
|
||||
*/
|
||||
internal fun decodeSizeBucketPx(px: Int): Int = when {
|
||||
px <= 0 -> 0
|
||||
px <= 64 -> 64
|
||||
px <= 96 -> 96
|
||||
px <= 144 -> 144
|
||||
px <= 256 -> 256
|
||||
px <= 384 -> 384
|
||||
else -> 512
|
||||
}
|
||||
|
||||
internal fun sizedMemoryCacheKey(baseKey: String, bucketPx: Int): String =
|
||||
if (bucketPx > 0) "$baseKey@$bucketPx" else baseKey
|
||||
|
||||
/** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */
|
||||
@Composable
|
||||
fun LlmpAsyncImage(
|
||||
@@ -237,6 +259,8 @@ fun LlmpAsyncImage(
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
stableCacheKey: String? = null,
|
||||
/** 正方形解码目标(逻辑 dp)。传入后按像素桶解码,显著降低老机 CPU/内存。 */
|
||||
decodeSize: Dp? = null,
|
||||
transform: (State) -> State = DefaultTransform,
|
||||
onState: ((State) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
@@ -247,24 +271,44 @@ fun LlmpAsyncImage(
|
||||
clipToBounds: Boolean = true,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val density = LocalDensity.current
|
||||
val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() }
|
||||
val cacheKey = stableCacheKey?.takeIf { it.isNotBlank() }
|
||||
val baseCacheKey = stableCacheKey?.takeIf { it.isNotBlank() }
|
||||
?: httpUrl?.toImageCacheKey()
|
||||
val bucketPx = remember(decodeSize, density) {
|
||||
decodeSize?.let { dp ->
|
||||
decodeSizeBucketPx(with(density) { dp.roundToPx() })
|
||||
} ?: 0
|
||||
}
|
||||
val memoryKey = baseCacheKey?.let { sizedMemoryCacheKey(it, bucketPx) }
|
||||
val resolvedFilterQuality = when {
|
||||
filterQuality != DefaultFilterQuality -> filterQuality
|
||||
bucketPx in 1..96 -> FilterQuality.Low
|
||||
else -> filterQuality
|
||||
}
|
||||
|
||||
// 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面
|
||||
// 可能 Error 一次并使占位图锁定,行仍在屏幕上。
|
||||
val request = remember(model, httpUrl, cacheKey) {
|
||||
val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(model)
|
||||
.crossfade(false)
|
||||
.apply {
|
||||
if (cacheKey != null) {
|
||||
memoryCacheKey(cacheKey)
|
||||
diskCacheKey(cacheKey)
|
||||
placeholderMemoryCacheKey(cacheKey)
|
||||
if (bucketPx > 0) {
|
||||
size(Size(bucketPx, bucketPx))
|
||||
}
|
||||
if (baseCacheKey != null) {
|
||||
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
|
||||
diskCacheKey(baseCacheKey)
|
||||
if (memoryKey != null) {
|
||||
memoryCacheKey(memoryKey)
|
||||
placeholderMemoryCacheKey(memoryKey)
|
||||
}
|
||||
listener(
|
||||
onSuccess = { _, result ->
|
||||
CoverImageHolder.put(cacheKey, result.image)
|
||||
if (memoryKey != null) {
|
||||
CoverImageHolder.put(memoryKey, result.image)
|
||||
}
|
||||
},
|
||||
onError = { _, result ->
|
||||
AppLog.w(
|
||||
@@ -287,7 +331,7 @@ fun LlmpAsyncImage(
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
filterQuality = resolvedFilterQuality,
|
||||
clipToBounds = clipToBounds,
|
||||
)
|
||||
}
|
||||
@@ -299,6 +343,7 @@ fun LlmpAsyncImage(
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
stableCacheKey: String? = null,
|
||||
decodeSize: Dp? = null,
|
||||
onLoading: ((State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((State.Success) -> Unit)? = null,
|
||||
onError: ((State.Error) -> Unit)? = null,
|
||||
@@ -314,6 +359,7 @@ fun LlmpAsyncImage(
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
stableCacheKey = stableCacheKey,
|
||||
decodeSize = decodeSize,
|
||||
onState = { state ->
|
||||
when (state) {
|
||||
is State.Loading -> onLoading?.invoke(state)
|
||||
|
||||
+1
@@ -333,6 +333,7 @@ private fun MenuList(
|
||||
music = coverMusic,
|
||||
size = ListLeadingCoverSize,
|
||||
radius = 12.dp,
|
||||
decodeToDisplaySize = true,
|
||||
modifier = heroMod,
|
||||
)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user