diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt index b2bc799..a21ec0d 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt @@ -52,6 +52,7 @@ fun AlbumGridItem( AlbumCoverImage( album = album, radius = 12.dp, + decodeToDisplaySize = true, modifier = Modifier.fillMaxSize().then(heroMod), ) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt index be88314..055f833 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt @@ -51,6 +51,7 @@ fun ArtistListItem( ArtistPhotoImage( artist = artist, size = ListLeadingCoverSize, + decodeToDisplaySize = true, modifier = Modifier.fillMaxSize().then(heroMod), ) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt index 4c7ed09..8101235 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt index b526ef8..0407214 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt index 4665046..85f15c9 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt index 95ce5d0..c0cc45c 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt index b0b9fba..2fab951 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt @@ -333,6 +333,7 @@ private fun MenuList( music = coverMusic, size = ListLeadingCoverSize, radius = 12.dp, + decodeToDisplaySize = true, modifier = heroMod, ) } else {