From 8f7552fa610e3bb2afbaec32e267a3b049365b38 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:18:30 +0530 Subject: [PATCH] feat: Add desktop image scaling support to NuvioAsyncImage --- .../app/core/ui/NuvioAsyncImage.android.kt | 1 + .../com/nuvio/app/core/ui/NuvioAsyncImage.kt | 6 ++ .../features/details/components/DetailHero.kt | 2 + .../home/components/HomeHeroSection.kt | 2 + .../app/core/ui/NuvioAsyncImage.desktop.kt | 99 ++++++++++++++++--- .../nuvio/app/core/ui/NuvioAsyncImage.ios.kt | 1 + 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt index 7e81dd3e0..fa48a20ca 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt @@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage( colorFilter: ColorFilter?, filterQuality: FilterQuality?, clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, ) { AsyncImage( model = model, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt index 26606686c..c773a3a7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 1b5ce1beb..99bf121c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index a7c9a1737..6b8a5a360 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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, ) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt index 78813b4d8..83e768088 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt @@ -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 { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt index 7e81dd3e0..fa48a20ca 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt @@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage( colorFilter: ColorFilter?, filterQuality: FilterQuality?, clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, ) { AsyncImage( model = model,