From cd5fe5fdf96cc6164df1df229d4fb8bb9a5fa428 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Sat, 11 Jul 2026 05:43:39 +0000 Subject: [PATCH] Fix CPU spike from library immersive mode blur Bake the background blur into the decoded bitmap once (quarter-res decode + box blur transformation) instead of re-running a fullscreen render effect every frame; also makes blur work below Android 12. Cache the shortcut list per refresh signal instead of rescanning containers on every focus move, and debounce focus changes 200ms so scrubbing the grid doesn't decode every intermediate hero image. --- .../main/app/shell/BoxBlurTransformation.kt | 94 +++++++++++++++++++ app/src/main/app/shell/UnifiedActivity.kt | 57 ++++++----- 2 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 app/src/main/app/shell/BoxBlurTransformation.kt diff --git a/app/src/main/app/shell/BoxBlurTransformation.kt b/app/src/main/app/shell/BoxBlurTransformation.kt new file mode 100644 index 000000000..6149d8174 --- /dev/null +++ b/app/src/main/app/shell/BoxBlurTransformation.kt @@ -0,0 +1,94 @@ +package com.winlator.cmod.app.shell + +import android.graphics.Bitmap +import coil.size.Size +import coil.transform.Transformation + +/** Bakes a blur into the decoded bitmap (two separable box passes ≈ Gaussian). */ +class BoxBlurTransformation( + private val radius: Int, +) : Transformation { + override val cacheKey = "boxBlur:$radius" + + override suspend fun transform( + input: Bitmap, + size: Size, + ): Bitmap { + if (radius < 1) return input + val w = input.width + val h = input.height + val a = IntArray(w * h) + val b = IntArray(w * h) + input.getPixels(a, 0, w, 0, 0, w, h) + repeat(2) { + horizontalPass(a, b, w, h) + verticalPass(b, a, w, h) + } + val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + out.setPixels(a, 0, w, 0, 0, w, h) + return out + } + + private fun horizontalPass( + src: IntArray, + dst: IntArray, + w: Int, + h: Int, + ) { + val div = 2 * radius + 1 + for (y in 0 until h) { + val row = y * w + var sa = 0 + var sr = 0 + var sg = 0 + var sb = 0 + for (i in -radius..radius) { + val p = src[row + i.coerceIn(0, w - 1)] + sa += p ushr 24 + sr += (p shr 16) and 0xFF + sg += (p shr 8) and 0xFF + sb += p and 0xFF + } + for (x in 0 until w) { + dst[row + x] = ((sa / div) shl 24) or ((sr / div) shl 16) or ((sg / div) shl 8) or (sb / div) + val add = src[row + (x + radius + 1).coerceAtMost(w - 1)] + val sub = src[row + (x - radius).coerceAtLeast(0)] + sa += (add ushr 24) - (sub ushr 24) + sr += ((add shr 16) and 0xFF) - ((sub shr 16) and 0xFF) + sg += ((add shr 8) and 0xFF) - ((sub shr 8) and 0xFF) + sb += (add and 0xFF) - (sub and 0xFF) + } + } + } + + private fun verticalPass( + src: IntArray, + dst: IntArray, + w: Int, + h: Int, + ) { + val div = 2 * radius + 1 + for (x in 0 until w) { + var sa = 0 + var sr = 0 + var sg = 0 + var sb = 0 + for (i in -radius..radius) { + val p = src[i.coerceIn(0, h - 1) * w + x] + sa += p ushr 24 + sr += (p shr 16) and 0xFF + sg += (p shr 8) and 0xFF + sb += p and 0xFF + } + for (y in 0 until h) { + dst[y * w + x] = ((sa / div) shl 24) or ((sr / div) shl 16) or ((sg / div) shl 8) or (sb / div) + val add = src[(y + radius + 1).coerceAtMost(h - 1) * w + x] + val sub = src[(y - radius).coerceAtLeast(0) * w + x] + sa += (add ushr 24) - (sub ushr 24) + sr += ((add shr 16) and 0xFF) - ((sub shr 16) and 0xFF) + sg += ((add shr 8) and 0xFF) - ((sub shr 8) and 0xFF) + sb += (add and 0xFF) - (sub and 0xFF) + } + } + } +} diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index e94f5775d..11b8e3ff6 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -91,7 +91,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.zIndex import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent @@ -2365,12 +2364,22 @@ class UnifiedActivity : if (immersiveMode && currentTabKeyForImmersive == "library") { val immersiveModel by immersiveBackgroundRef.collectAsState() val immersiveRequest = - remember(immersiveModel, context) { + remember(immersiveModel, immersiveBlur, context) { val builder = ImageRequest.Builder(context).data(immersiveModel) (immersiveModel as? java.io.File)?.takeIf { it.isFile }?.let { file -> // Custom uploads can be overwritten in place. val key = "library_immersive_bg:${file.absolutePath}:${file.lastModified()}" - builder.memoryCacheKey(key).diskCacheKey(key) + builder.memoryCacheKey(if (immersiveBlur) "$key:blur" else key).diskCacheKey(key) + } + if (immersiveBlur) { + // Blur is baked into the bitmap once at decode, so drawing it + // costs the same as a plain image every frame. Quarter-res + // decode + radius 2 ≈ an 8px blur at screen size. + val dm = context.resources.displayMetrics + builder + .size(dm.widthPixels / 4, dm.heightPixels / 4) + .scale(coil.size.Scale.FILL) + .transformations(BoxBlurTransformation(radius = 2)) } builder.crossfade(400).build() } @@ -2381,13 +2390,10 @@ class UnifiedActivity : modifier = Modifier.matchParentSize(), ) { Box(Modifier.matchParentSize()) { - val immersiveBlurRadius = with(LocalDensity.current) { 8f.toDp() } - val blurModifier = - if (immersiveBlur) Modifier.blur(immersiveBlurRadius) else Modifier AsyncImage( model = immersiveRequest, contentDescription = null, - modifier = Modifier.matchParentSize().then(blurModifier), + modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, ) Box( @@ -3970,36 +3976,39 @@ class UnifiedActivity : // Publish the focused game's hero artwork to drive the immersive background. // Prefers a custom Game Card upload (LibraryArtworkSlot.GAME_CARD), then the // store-supplied hero, then the regular grid capsule as a last resort. - // Reloads shortcuts via IO on every refresh signal so freshly uploaded artwork - // shows up immediately, mirroring LibraryGameLaunchScreen's lookup pattern. - LaunchedEffect( - focusIndex, - displayedApps, - shortcutRefreshKey, - libraryRefreshKey, - artworkCacheRefreshKey, - ) { + // Shortcuts are loaded via IO once per refresh signal (not per focus move) so + // freshly uploaded artwork still shows up immediately. + var immersiveShortcuts by remember { mutableStateOf?>(null) } + LaunchedEffect(shortcutRefreshKey, libraryRefreshKey, artworkCacheRefreshKey) { + immersiveShortcuts = + withContext(Dispatchers.IO) { ContainerManager(context).loadShortcuts() } + } + + LaunchedEffect(focusIndex, displayedApps, immersiveShortcuts) { + val shortcuts = immersiveShortcuts ?: return@LaunchedEffect val app = displayedApps.getOrNull(focusIndex) ?: displayedApps.firstOrNull() if (app == null) { activity?.immersiveBackgroundRef?.value = null return@LaunchedEffect } + // Debounce so scrubbing the grid doesn't decode every intermediate hero. + delay(200) val gogGame = visibleGogByPseudoId[app.id] val epicGame = visibleEpicByPseudoId[app.id] val isCustom = app.id < 0 val isEpic = app.id >= 2000000000 val epicId = if (isEpic) app.id - 2000000000 else 0 + val shortcut = + when { + gogGame != null -> + shortcuts.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + else -> findShortcutForGame(shortcuts, app, isCustom, isEpic, epicId) + } val customHeroFile = withContext(Dispatchers.IO) { - val shortcut = - when { - gogGame != null -> - ContainerManager(context).loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - else -> findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) - } shortcut ?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD.extraKey) ?.takeIf { it.isNotBlank() }