Fix scroll-back placeholders: in-memory bitmap cache in DensePhotoGrid

bitmapCache (MutableMap keyed on devicePath) lives at DensePhotoGrid
level and survives Compose cell recycling. DenseThumb checks memory
cache first — scroll-back is now instant for already-loaded cells.
Disk cache (ThumbnailCache) is only hit when bitmap not in memory.
This commit is contained in:
Kyle Bolen
2026-07-30 04:02:54 +00:00
parent 558a7add89
commit 716124bff5

View File

@@ -53,6 +53,11 @@ fun DensePhotoGrid(
onDeselectAllFolder: (List<PhonePhoto>) -> Unit, onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// In-memory bitmap cache — survives cell recycling during scroll
// keyed on cacheKey so it resets when device changes
val bitmapCache = remember(cacheKey) {
mutableMapOf<String, ImageBitmap>()
}
if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) { if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
@@ -144,6 +149,7 @@ fun DensePhotoGrid(
cacheKey = cacheKey, cacheKey = cacheKey,
pullFn = pullFn, pullFn = pullFn,
thumbnailVersion = thumbnailVersion, thumbnailVersion = thumbnailVersion,
bitmapCache = bitmapCache,
onToggle = { onTogglePhoto(labeled.photo) }, onToggle = { onTogglePhoto(labeled.photo) },
dateLabel = labeled.dateLabel, dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -167,13 +173,16 @@ private fun DenseThumb(
cacheKey: String, cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean, pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int, thumbnailVersion: Int,
bitmapCache: MutableMap<String, ImageBitmap>,
onToggle: () -> Unit, onToggle: () -> Unit,
dateLabel: String?, dateLabel: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) } // Check in-memory cache first (survives Compose cell recycling during scroll)
var thumbnail by remember(photo.devicePath) {
mutableStateOf(bitmapCache[photo.devicePath])
}
// Re-run when thumbnailVersion increments (new batch cached) or on first composition
LaunchedEffect(photo.devicePath, thumbnailVersion) { LaunchedEffect(photo.devicePath, thumbnailVersion) {
if (thumbnail == null) { if (thumbnail == null) {
val bytes = withContext(Dispatchers.IO) { val bytes = withContext(Dispatchers.IO) {
@@ -186,7 +195,9 @@ private fun DenseThumb(
} }
if (bytes != null && bytes.isNotEmpty()) { if (bytes != null && bytes.isNotEmpty()) {
runCatching { runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() val bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
bitmapCache[photo.devicePath] = bitmap // store in memory cache
thumbnail = bitmap
}.onFailure { e -> }.onFailure { e ->
thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}") thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
} }