feat: Add desktop image scaling support to NuvioAsyncImage

This commit is contained in:
tapframe 2026-06-09 16:18:30 +05:30
parent ab7fc0d1f9
commit 8f7552fa61
6 changed files with 98 additions and 13 deletions

View file

@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage(
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
AsyncImage(
model = model,

View file

@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
internal enum class NuvioDesktopImageScaling {
Auto,
Disabled,
}
@Composable
internal expect fun NuvioAsyncImage(
model: Any?,
@ -27,4 +32,5 @@ internal expect fun NuvioAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality? = null,
clipToBounds: Boolean = true,
desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto,
)

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
@ -108,6 +109,7 @@ fun DetailHero(
},
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
)
} else {
Box(

View file

@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
@ -181,6 +182,7 @@ fun HomeHeroSection(
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
)
}

View file

@ -24,14 +24,18 @@ import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.NullRequestDataException
import coil3.size.Size as CoilSize
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.FilterMipmap
import org.jetbrains.skia.FilterMode
import org.jetbrains.skia.Image as SkiaImage
import org.jetbrains.skia.MipmapMode
import kotlin.math.max
import kotlin.math.roundToInt
private const val MinCustomDownscaleRatio = 1.08f
private const val MaxDesktopSourceSizePx = 1536
private const val MaxScaledBitmapPixels = 1_250_000L
@Composable
internal actual fun NuvioAsyncImage(
model: Any?,
@ -49,19 +53,29 @@ internal actual fun NuvioAsyncImage(
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
val context = LocalPlatformContext.current
val requestModel = remember(context, model) {
model.withOriginalDesktopSize(context)
val requestModel = remember(context, model, desktopImageScaling) {
if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) {
model
} else {
model.withDesktopHighQualitySize(context)
}
}
val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(placeholder, error, fallback) {
val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(
placeholder,
error,
fallback,
desktopImageScaling,
) {
{ state ->
when (state) {
is AsyncImagePainter.State.Loading -> {
placeholder?.let { state.copy(painter = it) } ?: state
}
is AsyncImagePainter.State.Success -> {
state.result.image.toScaledBitmapPainter()
state.result.image.toScaledBitmapPainter(desktopImageScaling)
?.let { state.copy(painter = it) }
?: state
}
@ -107,26 +121,29 @@ internal actual fun NuvioAsyncImage(
)
}
private fun Any?.withOriginalDesktopSize(context: PlatformContext): Any? {
private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? {
if (this == null) return null
return if (this is ImageRequest) {
newBuilder()
.size(CoilSize.ORIGINAL)
.size(MaxDesktopSourceSizePx)
.build()
} else {
ImageRequest.Builder(context)
.data(this)
.size(CoilSize.ORIGINAL)
.size(MaxDesktopSourceSizePx)
.build()
}
}
private fun Image.toScaledBitmapPainter(): Painter? =
(this as? BitmapImage)
private fun Image.toScaledBitmapPainter(desktopImageScaling: NuvioDesktopImageScaling): Painter? {
if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) return null
return (this as? BitmapImage)
?.bitmap
?.asComposeImageBitmap()
?.let { imageBitmap -> ScaledBitmapPainter(imageBitmap) }
}
private class ScaledBitmapPainter(
private val image: ImageBitmap,
@ -144,17 +161,28 @@ private class ScaledBitmapPainter(
width = size.width.roundToInt().coerceAtLeast(1),
height = size.height.roundToInt().coerceAtLeast(1),
)
val bitmap = scaledBitmap(drawSize)
if (!shouldUseScaledBitmap(drawSize)) {
drawSource(drawSize)
return
}
val cacheSize = drawSize.cacheSize()
if (cacheSize.pixelCount() > MaxScaledBitmapPixels) {
drawSource(drawSize)
return
}
val bitmap = scaledBitmap(cacheSize)
drawImage(
image = bitmap,
srcOffset = IntOffset.Zero,
srcSize = drawSize,
srcSize = cacheSize,
dstOffset = IntOffset.Zero,
dstSize = drawSize,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = FilterQuality.None,
filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium,
)
}
@ -178,6 +206,51 @@ private class ScaledBitmapPainter(
}
}
private fun DrawScope.drawSource(drawSize: IntSize) {
drawImage(
image = image,
srcOffset = IntOffset.Zero,
srcSize = IntSize(image.width, image.height),
dstOffset = IntOffset.Zero,
dstSize = drawSize,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = FilterQuality.High,
)
}
private fun shouldUseScaledBitmap(drawSize: IntSize): Boolean {
if (drawSize.pixelCount() > MaxScaledBitmapPixels) return false
val widthScale = image.width.toFloat() / drawSize.width.toFloat()
val heightScale = image.height.toFloat() / drawSize.height.toFloat()
return max(widthScale, heightScale) >= MinCustomDownscaleRatio
}
private fun IntSize.cacheSize(): IntSize {
val quantum = cacheQuantum()
return IntSize(
width = width.roundUpTo(quantum),
height = height.roundUpTo(quantum),
)
}
private fun IntSize.cacheQuantum(): Int {
val longestSide = max(width, height)
return when {
longestSide >= 1200 -> 16
longestSide >= 600 -> 8
longestSide >= 200 -> 4
else -> 2
}
}
private fun Int.roundUpTo(quantum: Int): Int =
((this + quantum - 1) / quantum) * quantum
private fun IntSize.pixelCount(): Long =
width.toLong() * height.toLong()
private fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap {
val image = SkiaImage.makeFromBitmap(asSkiaBitmap())
return try {

View file

@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage(
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
AsyncImage(
model = model,