From 045b689994b9e458af9f372224368a71dc431e1e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:11:37 -0500 Subject: [PATCH] Add ability to sort and filter items in a playlist (#818) ## Description Add ability to sort and filter items in a playlist. There's a new button for each added to the playlist details page. Starting playback will run in the order selected. The sort and filter are persisted locally per playlist, so returning to the playlist will remember the previous settings. Note: playlist playback is still limited to 100 items (#42) ### Related issues Closes #285 ### Testing Emulator & nvidia shield ## Screenshots ![playlist_sort_filter Large](https://github.com/user-attachments/assets/9880021b-a1c3-4d8d-ace2-83737f3f772a) ## AI or LLM usage None --- .../wholphin/data/filter/ItemFilterBy.kt | 11 + .../wholphin/services/PlaylistCreator.kt | 40 +- .../ui/components/CollectionFolderGrid.kt | 93 +--- .../wholphin/ui/detail/PlaylistDetails.kt | 486 ++++++++++++------ .../wholphin/ui/util/FilterUtils.kt | 113 ++++ 5 files changed, 497 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 92dbecf3..fec0fb8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions = DecadeFilter, ) +val DefaultPlaylistItemsOptions = + listOf( + PlayedFilter, + FavoriteFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + sealed interface ItemFilterBy { @get:StringRes val stringRes: Int diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 824342d5..4231d9b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject @@ -79,19 +77,22 @@ class PlaylistCreator suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, - shuffled: Boolean, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, ): Playlist { val request = - GetPlaylistItemsRequest( - playlistId = playlistId, - fields = DefaultItemFields, - startIndex = startIndex, - limit = Playlist.MAX_SIZE, + filter.applyTo( + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = playlistId, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), ) - var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - if (shuffled) { - items = items.shuffled() - } + val items = GetItemsRequestHandler.execute(api, request).content.items return Playlist(items.convertAndAddParts(), 0) } @@ -206,9 +207,18 @@ class PlaylistCreator BaseItemKind.PLAYLIST -> { PlaylistCreationResult.Success( createFromPlaylistId( - item.id, - startIndex, - shuffled, + playlistId = item.id, + startIndex = startIndex, + sortAndDirection = + if (shuffled) { + SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) + } else { + sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ) + }, + filter = filter, ), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 3fa8ffa5..29991126 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -47,18 +47,9 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt 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.ui.util.FilterUtils import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.yearsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.TreeSet import java.util.UUID import kotlin.time.Duration @@ -391,79 +379,12 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet() - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 5bf996a3..7e552525 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions +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.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -61,26 +71,39 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.FilterByButton import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -92,9 +115,22 @@ class PlaylistViewModel api: ApiClient, val navigationManager: NavigationManager, private val backdropService: BackdropService, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Pending) val items = MutableLiveData>(listOf()) + val filterAndSort = + MutableStateFlow( + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + ) fun init(playlistId: UUID) { loading.value = LoadingState.Loading @@ -103,19 +139,93 @@ class PlaylistViewModel LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), ) { val playlist = fetchItem(playlistId) - val request = - GetPlaylistItemsRequest( - playlistId = playlist.id, - fields = DefaultItemFields, + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, ) - val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success + loadItems(filter, sortAndDirection) + } + } + + fun loadItems( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ) { + viewModelScope.launchIO { + backdropService.clearBackdrop() + loading.setValueOnMain(LoadingState.Loading) + this@PlaylistViewModel.filterAndSort.update { + FilterAndSort(filter, sortAndDirection) + } + + serverRepository.currentUser.value?.let { user -> + val playlistId = item.value!!.id + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + + val request = + filter.applyTo( + GetItemsRequest( + parentId = playlistId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + try { + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + withContext(Dispatchers.Main) { + items.value = listOf() + loading.value = LoadingState.Error(ex) + } + } } } } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) @@ -123,6 +233,12 @@ class PlaylistViewModel } } +@Immutable +data class FilterAndSort( + val filter: GetItemsFilter, + val sortAndDirection: SortAndDirection, +) + @Composable fun PlaylistDetails( destination: Destination.MediaItem, @@ -136,78 +252,75 @@ fun PlaylistDetails( val loading by viewModel.loading.observeAsState(LoadingState.Pending) val playlist by viewModel.item.observeAsState(null) val items by viewModel.items.observeAsState(listOf()) + val filterAndSort by viewModel.filterAndSort.collectAsState() var longClickDialog by remember { mutableStateOf(null) } - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + val goToString = stringResource(R.string.go_to) + val playFromHereString = stringResource(R.string.play_from_here) - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - playlist?.let { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - PlaylistDetailsContent( - playlist = it, - items = items, - focusRequester = focusRequester, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, - onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = 0, - shuffle = shuffle, - ), - ) - }, - onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - listOf( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - context.getString(R.string.play_from_here), - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, + PlaylistDetailsContent( + loadingState = loading, + playlist = playlist, + items = items, + onChangeBackdrop = viewModel::updateBackdrop, + onClickIndex = { index, _ -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = 0, + shuffle = shuffle, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + goToString, + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo(item.destination()) + }, + DialogItem( + playFromHereString, + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, ), - ) - }, - modifier = modifier, + ) + }, + ), ) - } - } - } + }, + filterAndSort = filterAndSort, + onFilterAndSortChange = viewModel::loadItems, + getPossibleFilterValues = viewModel::getFilterOptionValues, + modifier = modifier, + ) longClickDialog?.let { params -> DialogPopup( params = params, @@ -218,14 +331,17 @@ fun PlaylistDetails( @Composable fun PlaylistDetailsContent( - playlist: BaseItem, + playlist: BaseItem?, items: List, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + loadingState: LoadingState, modifier: Modifier = Modifier, - focusRequester: FocusRequester = remember { FocusRequester() }, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } @@ -234,6 +350,12 @@ fun PlaylistDetailsContent( LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(loadingState) { + if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { + focusRequester.tryRequestFocus() + } + } val playButtonFocusRequester = remember { FocusRequester() } @@ -248,83 +370,123 @@ fun PlaylistDetailsContent( .fillMaxSize(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( focusedItem = focusedItem, onClickPlay = onClickPlay, playButtonFocusRequester = playButtonFocusRequester, + focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, + filterAndSort = filterAndSort, + onFilterAndSortChange = onFilterAndSortChange, + getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp) + .padding(start = 16.dp, top = 80.dp) .fillMaxWidth(.25f), ) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - modifier = - Modifier - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = playlist?.name ?: stringResource(R.string.playlist), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = index + item?.let { + onClickIndex.invoke(index, item) + } + }, + onLongClick = { + savedIndex = index + item?.let { + onLongClickIndex.invoke(index, item) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusable(), + ) + } + } } } } @@ -338,6 +500,10 @@ fun PlaylistDetailsHeader( focusedItem: BaseItem?, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, + focusRequester: FocusRequester, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, modifier: Modifier = Modifier, ) { Column( @@ -346,13 +512,14 @@ fun PlaylistDetailsHeader( ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.focusRequester(playButtonFocusRequester), + modifier = Modifier, ) { ExpandablePlayButton( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, onClick = { onClickPlay.invoke(false) }, + modifier = Modifier.focusRequester(playButtonFocusRequester), ) ExpandableFaButton( title = R.string.shuffle, @@ -360,16 +527,45 @@ fun PlaylistDetailsHeader( onClick = { onClickPlay.invoke(true) }, ) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + FilterByButton( + filterOptions = DefaultPlaylistItemsOptions, + current = filterAndSort.filter, + onFilterChange = { + onFilterAndSortChange.invoke( + it, + filterAndSort.sortAndDirection, + ) + }, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(focusRequester), + ) + SortByButton( + sortOptions = BoxSetSortOptions, + current = filterAndSort.sortAndDirection, + onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, + ) + } Text( text = focusedItem?.title ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineLarge, + style = MaterialTheme.typography.headlineSmall, ) Text( text = focusedItem?.subtitle ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, ) + if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { + Text( + text = formatDateTime(focusedItem.data.premiereDate!!), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + } OverviewText( overview = focusedItem?.data?.overview ?: "", maxLines = 10, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt new file mode 100644 index 00000000..5358a14e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -0,0 +1,113 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import java.util.UUID + +object FilterUtils { + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + api: ApiClient, + userId: UUID?, + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ): List = + withContext(Dispatchers.IO) { + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + api.localizationApi.getParentalRatings().content.map { + FilterValueOption(it.name ?: "", it.value) + } + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception get filter value options for $filterOption") + listOf() + } + } +}