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() }