Fixes favorites page not loading (#1462)

## Description
- Fixes a bug introduced in #1453 which broke the favorite page
- Follow up to #1428 to refactor the collection folder code to hoist
state up another level
- Fixes resuming playback from series details play button

### Related issues
Fixes #1313
Fixes #1461
Follow up to #1453 & #1428 

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-25 20:05:44 -04:00 committed by GitHub
parent 510160aceb
commit 23856ffcf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 294 additions and 396 deletions

View file

@ -16,21 +16,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -44,17 +36,13 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemSortBy
@ -64,76 +52,25 @@ import kotlin.time.Duration
@Composable
fun CollectionFolderGrid(
preferences: UserPreferences,
item: BaseItem?,
title: String,
items: DataLoadingState<List<BaseItem?>>,
collectionType: CollectionType?,
focusedItem: BaseItem?,
items: List<BaseItem?>,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
viewOptions: ViewOptions,
onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
onClickShowViewOptions: () -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
focusRequesterOnEmpty: FocusRequester? = null,
) {
val context = LocalContext.current
val pager = (items as? DataLoadingState.Success)?.data
var showHeader by rememberSaveable { mutableStateOf(true) }
val headerRowFocusRequester = remember { FocusRequester() }
val gridFocusRequester = remember { FocusRequester() }
if (pager?.isNotEmpty() == true) {
RequestOrRestoreFocus(gridFocusRequester)
} else {
LaunchedEffect(Unit) {
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
}
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
var position by rememberInt(initialPosition)
val focusedItem = pager?.getOrNull(position)
if (viewOptions.showBackdrop) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
}
Box(modifier = modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
) {
CollectionFolderHeader(
showHeader = showHeader || items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && pager?.isNotEmpty() == true,
title = title,
sortAndDirection = sortAndDirection,
onSortChange = onSortChange,
sortOptions = sortOptions,
getPossibleFilterValues = getPossibleFilterValues,
onClickShowViewOptions = onClickShowViewOptions,
onClickPlayAll = onClickPlayAll,
currentFilter = currentFilter,
filterOptions = filterOptions,
onFilterChange = onFilterChange,
modifier = Modifier.focusRequester(headerRowFocusRequester),
)
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val density = LocalDensity.current
AnimatedVisibility(viewOptions.showDetails) {
@ -147,21 +84,8 @@ fun CollectionFolderGrid(
.padding(HeaderUtils.padding),
)
}
when (val state = items) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
Text(stringResource(R.string.loading))
}
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception)
}
is DataLoadingState.Success<List<BaseItem?>> -> {
CardGrid(
pager = state.data,
pager = items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
@ -171,11 +95,7 @@ fun CollectionFolderGrid(
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition,
positionCallback = { columns, newPosition ->
showHeader = newPosition < columns
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
positionCallback = positionCallback,
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
@ -204,8 +124,6 @@ fun CollectionFolderGrid(
}
}
}
}
}
data class PositionItem(
val position: Int,

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -51,10 +50,7 @@ import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
@ -67,7 +63,6 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
@ -79,60 +74,30 @@ import org.jellyfin.sdk.model.extensions.ticks
@Composable
fun CollectionFolderList(
preferences: UserPreferences,
item: BaseItem?,
title: String,
items: DataLoadingState<List<BaseItem?>>,
collectionType: CollectionType?,
focusedItem: BaseItem?,
items: List<BaseItem?>,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
viewOptions: ViewOptions,
onClickPlayAll: (shuffle: Boolean) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
onClickShowViewOptions: () -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
focusRequesterOnEmpty: FocusRequester? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pager = (items as? DataLoadingState.Success)?.data
var showHeader by rememberSaveable { mutableStateOf(true) }
val headerRowFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
val gridFocusRequester = remember { FocusRequester() }
if (pager?.isNotEmpty() == true) {
LaunchedEffect(viewOptions.type) {
gridFocusRequester.tryRequestFocus()
}
} else {
LaunchedEffect(Unit) {
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
}
}
val listState = rememberLazyListState()
var contentHashFocus by remember { mutableStateOf(false) }
var position by rememberInt(initialPosition)
val positionFocusRequester = remember { FocusRequester() }
val focusedItem = pager?.getOrNull(position)
if (viewOptions.showBackdrop) {
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
}
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
var showHeader by remember { mutableStateOf(true) }
fun jumpTo(newPosition: Int) {
scope.launch {
@ -147,36 +112,6 @@ fun CollectionFolderList(
}
Column(modifier = modifier) {
CollectionFolderHeader(
showHeader = showHeader || items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && pager?.isNotEmpty() == true,
title = title,
sortAndDirection = sortAndDirection,
onSortChange = onSortChange,
sortOptions = sortOptions,
getPossibleFilterValues = getPossibleFilterValues,
onClickShowViewOptions = onClickShowViewOptions,
onClickPlayAll = onClickPlayAll,
currentFilter = currentFilter,
filterOptions = filterOptions,
onFilterChange = onFilterChange,
modifier = Modifier.focusRequester(headerRowFocusRequester),
)
when (val state = items) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
Text(stringResource(R.string.loading))
}
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception)
}
is DataLoadingState.Success<List<BaseItem?>> -> {
val pager = state.data
val topPadding by animateDpAsState(
targetValue =
when {
@ -214,9 +149,9 @@ fun CollectionFolderList(
)
}
CollectionFolderContent(
items = state.data,
items = items,
viewOptions = viewOptions,
collectionType = item?.data?.collectionType,
collectionType = collectionType,
listState = listState,
position = position,
positionFocusRequester = positionFocusRequester,
@ -249,7 +184,7 @@ fun CollectionFolderList(
}
?: letters[0]
}
if (showLetterButtons && pager.isNotEmpty()) {
if (showLetterButtons && items.isNotEmpty()) {
AlphabetButtons(
letters = letters,
currentLetter = currentLetter,
@ -268,8 +203,6 @@ fun CollectionFolderList(
}
}
}
}
}
@OptIn(ExperimentalCoilApi::class)
@Composable

View file

@ -4,11 +4,13 @@ import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -18,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -60,8 +63,10 @@ import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
@ -149,10 +154,12 @@ class CollectionFolderViewModel
viewModelScope.launchIO {
try {
val item =
itemId.toUUIDOrNull()?.let {
api.userLibraryApi
.getItem(itemId.toUUID())
.getItem(it)
.content
.let(::BaseItem)
}
val libraryDisplayInfo =
serverRepository.currentUser?.let { user ->
@ -184,7 +191,11 @@ class CollectionFolderViewModel
// onResumePage()
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
_state.update {
it.copy(
item = DataLoadingState.Error(ex),
)
}
}
}
mediaManagementService.deletedItemFlow
@ -313,7 +324,12 @@ class CollectionFolderViewModel
sortAndDirection,
filter,
)
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
_state.update {
it.copy(
items = DataLoadingState.Error(ex),
backgroundLoading = LoadingState.Pending,
)
}
}
}
@ -516,7 +532,7 @@ class CollectionFolderViewModel
}
data class CollectionFolderState(
val item: DataLoadingState<BaseItem> = DataLoadingState.Loading,
val item: DataLoadingState<BaseItem?> = DataLoadingState.Loading,
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading,
val backgroundLoading: LoadingState = LoadingState.Loading,
val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT,
@ -738,24 +754,27 @@ fun CollectionFolderView(
},
)
}
Box(modifier = modifier) {
when (val st = state.item) {
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
LoadingPage(Modifier.fillMaxSize())
}
is DataLoadingState.Error,
is DataLoadingState.Success<*>,
-> {
is DataLoadingState.Error -> {
ErrorMessage(st, Modifier.fillMaxSize())
}
is DataLoadingState.Success<BaseItem?> -> {
val item = st.successValue
val title =
initialFilter.nameOverride
?: item?.name
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
val headerRowFocusRequester = remember { FocusRequester() }
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
@ -763,22 +782,27 @@ fun CollectionFolderView(
viewModel.release()
}
}
if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid(
preferences = preferences,
initialPosition = viewModel.position,
item = item,
var showHeader by rememberSaveable { mutableStateOf(true) }
val gridFocusRequester = remember { FocusRequester() }
val pager = remember(state.items) { state.items.successValue }
var position by rememberInt(viewModel.position)
val focusedItem = pager?.getOrNull(position)
if (state.viewOptions.showBackdrop) {
LaunchedEffect(focusedItem) {
focusedItem?.let(viewModel::updateBackdrop)
}
}
CollectionFolderHeader(
showHeader = showHeader || state.items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && state.items.successValue?.isNotEmpty() == true,
title = title,
items = state.items,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
onSortChange = {
viewModel.onSortChange(it, recursive, state.filter)
},
filterOptions = filterOptions,
sortOptions = sortOptions,
currentFilter = state.filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
@ -786,60 +810,79 @@ fun CollectionFolderView(
getPossibleFilterValues = {
viewModel.getFilterOptionValues(it)
},
showTitle = showTitle,
sortOptions = sortOptions,
positionCallback = { columns, position ->
viewModel.position = position
positionCallback?.invoke(columns, position)
filterOptions = filterOptions,
onClickPlayAll = gridActions.onClickPlayAll!!,
onClickShowViewOptions = { showViewOptions = true },
modifier = Modifier.focusRequester(headerRowFocusRequester),
)
when (val pager = state.items) {
is DataLoadingState.Error -> {
LaunchedEffect(Unit) {
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
}
ErrorMessage(pager, Modifier.fillMaxSize())
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(Modifier.fillMaxSize())
}
is DataLoadingState.Success<List<BaseItem?>> -> {
LaunchedEffect(Unit) {
if (pager.data.isNotEmpty()) {
gridFocusRequester.tryRequestFocus()
}
}
Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = viewModel.position,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
gridFocusRequester = gridFocusRequester,
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
positionCallback = { columns, pos ->
viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
onClickPlayAll = gridActions.onClickPlayAll!!,
onClickShowViewOptions = { showViewOptions = true },
focusedItem = focusedItem,
)
} else {
CollectionFolderList(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = viewModel.position,
item = item,
title = title,
items = state.items,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
gridFocusRequester = gridFocusRequester,
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
onSortChange = {
viewModel.onSortChange(it, recursive, state.filter)
},
filterOptions = filterOptions,
currentFilter = state.filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
},
getPossibleFilterValues = {
viewModel.getFilterOptionValues(it)
},
showTitle = showTitle,
sortOptions = sortOptions,
positionCallback = { columns, position ->
viewModel.position = position
positionCallback?.invoke(columns, position)
positionCallback = { columns, pos ->
viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
onClickPlayAll = gridActions.onClickPlayAll!!,
onClickShowViewOptions = { showViewOptions = true },
focusedItem = focusedItem,
)
}
AnimatedVisibility(
androidx.compose.animation.AnimatedVisibility(
state.backgroundLoading == LoadingState.Loading,
modifier =
Modifier
@ -858,6 +901,10 @@ fun CollectionFolderView(
}
}
}
}
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,

View file

@ -492,7 +492,7 @@ class SeriesViewModel
.firstOrNull()
if (nextUp != null) {
withContext(Dispatchers.Main) {
navigateTo(Destination.Playback(nextUp.id, 0L))
navigateTo(Destination.Playback(BaseItem(nextUp)))
}
} else {
showToast(