Keep grid controls accessible if error occurs (#714)

## Description
If an error occurs while fetching data for the grid, leave the controls
accessible. This is useful in case a particular sort or filter causes an
error because the app remembers those values between page loads.

Essentially the error message is shown in place of the grid content
instead of the entire tab.

### Related issues
Fixes #712
This commit is contained in:
Ray 2026-01-16 17:15:16 -05:00 committed by GitHub
parent 88de065fe0
commit bbeb42dab9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -89,10 +89,10 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -151,9 +151,8 @@ class CollectionFolderViewModel
): CollectionFolderViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableLiveData<ViewOptions>()
@ -165,40 +164,40 @@ class CollectionFolderViewModel
}
init {
viewModelScope.launch(
LoadingExceptionHandler(
loading,
context.getString(R.string.error_loading_collection, itemId),
) + Dispatchers.IO,
) {
viewModelScope.launchIO {
super.itemId = itemId
itemId.toUUIDOrNull()?.let {
fetchItem(it)
try {
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) {
libraryDisplayInfo?.sortAndDirection
} else {
null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
val filterToUse =
if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
collectionFilter.filter.merge(libraryDisplayInfo.filter)
} else {
collectionFilter.filter
}
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex))
}
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) {
libraryDisplayInfo?.sortAndDirection
} else {
null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
val filterToUse =
if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
collectionFilter.filter.merge(libraryDisplayInfo.filter)
} else {
collectionFilter.filter
}
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
}
}
@ -269,8 +268,7 @@ class CollectionFolderViewModel
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
if (resetState) {
pager.value = listOf()
loading.value = LoadingState.Loading
loading.value = DataLoadingState.Loading
}
backgroundLoading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
@ -281,8 +279,7 @@ class CollectionFolderViewModel
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager
loading.value = LoadingState.Success
loading.value = DataLoadingState.Success(newPager)
backgroundLoading.value = LoadingState.Success
}
} catch (ex: Exception) {
@ -293,8 +290,7 @@ class CollectionFolderViewModel
filter,
)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Error(ex)
pager.value = listOf()
loading.value = DataLoadingState.Error(ex)
}
}
}
@ -498,7 +494,9 @@ class CollectionFolderViewModel
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
(loading.value as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
fun setFavorite(
@ -507,7 +505,9 @@ class CollectionFolderViewModel
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
(loading.value as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
fun updateBackdrop(item: BaseItem) {
@ -598,7 +598,6 @@ fun CollectionFolderGrid(
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState()
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
@ -606,95 +605,91 @@ fun CollectionFolderGrid(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage()
}
LoadingState.Success -> {
pager?.let { pager ->
val title =
initialFilter.nameOverride
?: item?.name
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
item = item,
title = title,
pager = pager,
sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
},
onSortChange = {
viewModel.onSortChange(it, recursive, filter)
},
filterOptions = filterOptions,
currentFilter = filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
},
getPossibleFilterValues = {
viewModel.getFilterOptionValues(it)
},
showTitle = showTitle,
sortOptions = sortOptions,
positionCallback = { columns, position ->
viewModel.position = position
positionCallback?.invoke(columns, position)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
),
)
}
},
)
is DataLoadingState.Error,
is DataLoadingState.Success<*>,
-> {
val title =
initialFilter.nameOverride
?: item?.name
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
item = item,
title = title,
loadingState = state as DataLoadingState<List<BaseItem?>>,
sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
},
onSortChange = {
viewModel.onSortChange(it, recursive, filter)
},
filterOptions = filterOptions,
currentFilter = filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
},
getPossibleFilterValues = {
viewModel.getFilterOptionValues(it)
},
showTitle = showTitle,
sortOptions = sortOptions,
positionCallback = { columns, position ->
viewModel.position = position
positionCallback?.invoke(columns, position)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
),
)
}
},
)
AnimatedVisibility(
backgroundLoading == LoadingState.Loading,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
) {
CircularProgress(
Modifier
.background(
MaterialTheme.colorScheme.background.copy(alpha = .25f),
shape = CircleShape,
).size(64.dp)
.padding(4.dp),
)
}
AnimatedVisibility(
backgroundLoading == LoadingState.Loading,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
) {
CircularProgress(
Modifier
.background(
MaterialTheme.colorScheme.background.copy(alpha = .25f),
shape = CircleShape,
).size(64.dp)
.padding(4.dp),
)
}
}
}
@ -755,7 +750,7 @@ fun CollectionFolderGridContent(
preferences: UserPreferences,
item: BaseItem?,
title: String,
pager: List<BaseItem?>,
loadingState: DataLoadingState<List<BaseItem?>>,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
@ -781,13 +776,14 @@ fun CollectionFolderGridContent(
) {
val context = LocalContext.current
val pager = (loadingState as? DataLoadingState.Success)?.data
var showHeader by rememberSaveable { mutableStateOf(true) }
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var viewOptions by remember { mutableStateOf(viewOptions) }
val headerRowFocusRequester = remember { FocusRequester() }
val gridFocusRequester = remember { FocusRequester() }
if (pager.isNotEmpty()) {
if (pager?.isNotEmpty() == true) {
RequestOrRestoreFocus(gridFocusRequester)
} else {
LaunchedEffect(Unit) {
@ -797,7 +793,7 @@ fun CollectionFolderGridContent(
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
var position by rememberInt(initialPosition)
val focusedItem = pager.getOrNull(position)
val focusedItem = pager?.getOrNull(position)
if (viewOptions.showDetails) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
@ -810,7 +806,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxSize(),
) {
AnimatedVisibility(
showHeader,
showHeader || loadingState !is DataLoadingState.Success,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
@ -871,7 +867,7 @@ fun CollectionFolderGridContent(
)
}
}
if (playEnabled) {
if (playEnabled && pager?.isNotEmpty() == true) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -903,47 +899,62 @@ fun CollectionFolderGridContent(
.padding(16.dp),
)
}
CardGrid(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition,
positionCallback = { columns, newPosition ->
showHeader = newPosition < columns
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
cardContent = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
modifier = mod,
when (val state = loadingState) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
Text("Loading")
}
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception)
}
is DataLoadingState.Success<List<BaseItem?>> -> {
CardGrid(
pager = state.data,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition,
positionCallback = { columns, newPosition ->
showHeader = newPosition < columns
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
cardContent = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
modifier = mod,
)
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
)
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
onSaveViewOptions.invoke(viewOptions)
},
onViewOptionsChange = { viewOptions = it },
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
onSaveViewOptions.invoke(viewOptions)
},
onViewOptionsChange = { viewOptions = it },
)
}
}
}
}
}