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) private val _selectedDevice = MutableStateFlow<DeviceInfo?>(null)
val selectedDevice: StateFlow<DeviceInfo?> = _selectedDevice.asStateFlow() 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() { fun scanForDevices() {
scope.launch { scope.launch {
_deviceScan.value = DeviceScanState.Scanning _deviceScan.value = DeviceScanState.Scanning
@@ -240,6 +244,10 @@ class AppState(private val configStore: ConfigStore) {
} }
} }
loaded += batchLoaded 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})") log.info("iOS thumbnails: $loaded / ${needed.size} loaded (batch ${batchIdx + 1}/${(needed.size + batchSize - 1) / batchSize})")
} catch (e: Exception) { } catch (e: Exception) {
log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}") log.warn("iOS thumbnail batch ${batchIdx + 1} failed: ${e.message}")

View File

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

View File

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