Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/src/main/app/shell/BoxBlurTransformation.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
57 changes: 33 additions & 24 deletions app/src/main/app/shell/UnifiedActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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(
Expand Down Expand Up @@ -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<List<Shortcut>?>(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() }
Expand Down