Fix iOS thumbnails: thumbnailVersion triggers DenseThumb retry

When the background batch pre-fetch writes new thumbnails to cache,
thumbnailVersion increments. DenseThumb uses it as a LaunchedEffect key
so cells retry loading from cache after each batch completes.

Also removed thumbFailed permanent gate — thumbnails retry on each
thumbnailVersion change instead of giving up after one failure.
This commit is contained in:
Kyle Bolen
2026-07-30 03:28:31 +00:00
parent eef4f61050
commit 309031aed7
3 changed files with 17 additions and 6 deletions

View File

@@ -94,6 +94,10 @@ class AppState(private val configStore: ConfigStore) {
private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow()
/** Incremented after each thumbnail batch completes — DenseThumb observes this to retry */
private val _thumbnailVersion = MutableStateFlow(0)
val thumbnailVersion: StateFlow<Int> = _thumbnailVersion.asStateFlow()
fun scanForDevices() {
scope.launch {
_deviceScan.value = DeviceScanState.Scanning
@@ -240,6 +244,10 @@ class AppState(private val configStore: ConfigStore) {
}
}
loaded += batchLoaded
if (batchLoaded > 0) {
// Notify UI that new thumbnails are available
_thumbnailVersion.value++
}
log.info("iOS thumbnails: $loaded / ${needed.size} loaded (batch ${batchIdx + 1}/${(needed.size + batchSize - 1) / batchSize})")
} catch (e: Exception) {
log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}")

View File

@@ -92,6 +92,7 @@ fun BrowseScreen(state: AppState) {
VerticalDivider()
val thumbnailVersion by state.thumbnailVersion.collectAsState()
DensePhotoGrid(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
@@ -101,6 +102,7 @@ fun BrowseScreen(state: AppState) {
state.connectorFor(d).pullToFile(d, devicePath, localPath)
} ?: false
},
thumbnailVersion = thumbnailVersion,
columnCount = columnCount,
listState = listState,
onTogglePhoto = { state.togglePhotoSelection(it) },

View File

@@ -43,6 +43,7 @@ fun DensePhotoGrid(
selectedPhotos: Set<PhonePhoto>,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int = 0,
columnCount: Int,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit,
@@ -140,6 +141,7 @@ fun DensePhotoGrid(
isSelected = labeled.photo in selectedPhotos,
cacheKey = cacheKey,
pullFn = pullFn,
thumbnailVersion = thumbnailVersion,
onToggle = { onTogglePhoto(labeled.photo) },
dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f),
@@ -162,15 +164,16 @@ private fun DenseThumb(
isSelected: Boolean,
cacheKey: String,
pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean,
thumbnailVersion: Int,
onToggle: () -> Unit,
dateLabel: String?,
modifier: Modifier = Modifier,
) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
LaunchedEffect(photo.devicePath) {
if (!thumbFailed && thumbnail == null) {
// Re-run when thumbnailVersion increments (new batch cached) or on first composition
LaunchedEffect(photo.devicePath, thumbnailVersion) {
if (thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(
cacheKey = cacheKey,
@@ -182,9 +185,7 @@ private fun DenseThumb(
if (bytes != null) {
runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}.onFailure { thumbFailed = true }
} else {
thumbFailed = true
}
}
}
}