Compare commits

...

2 Commits

Author SHA1 Message Date
Kyle Bolen
fbd228b768 Add decode failure logging to DenseThumb to diagnose sparse thumbnails 2026-07-30 03:30:47 +00:00
Kyle Bolen
309031aed7 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.
2026-07-30 03:28:31 +00:00
3 changed files with 22 additions and 7 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

@@ -31,6 +31,8 @@ import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
private val thumbLog = org.slf4j.LoggerFactory.getLogger("DenseThumb")
/** /**
* Dense grid: folder headers + flat photo grid per folder. * Dense grid: folder headers + flat photo grid per folder.
* Each photo that is the first of its day gets a small date label * Each photo that is the first of its day gets a small date label
@@ -43,6 +45,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 +143,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 +166,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,
@@ -179,12 +184,12 @@ private fun DenseThumb(
pullFn = pullFn, pullFn = pullFn,
) )
} }
if (bytes != null) { if (bytes != null && bytes.isNotEmpty()) {
runCatching { runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}.onFailure { thumbFailed = true } }.onFailure { e ->
} else { thumbLog.warn("Decode failed for ${photo.filename} (${bytes.size} bytes): ${e.message}")
thumbFailed = true }
} }
} }
} }