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
This commit is contained in:
Ray 2026-02-03 15:11:37 -05:00 committed by GitHub
parent ae5035703f
commit 045b689994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 497 additions and 246 deletions

View file

@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter,
)
val DefaultPlaylistItemsOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> {
@get:StringRes
val stringRes: Int

View file

@ -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,
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,
),
)
}

View file

@ -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<FilterValueOption> =
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),
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
}
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<Int>()
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()
}
suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) {

View file

@ -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>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf())
val filterAndSort =
MutableStateFlow<FilterAndSort>(
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
)
fun init(playlistId: UUID) {
loading.value = LoadingState.Loading
@ -103,18 +139,92 @@ 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()
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<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
@ -123,6 +233,12 @@ class PlaylistViewModel
}
}
@Immutable
data class FilterAndSort(
val filter: GetItemsFilter,
val sortAndDirection: SortAndDirection,
)
@Composable
fun PlaylistDetails(
destination: Destination.MediaItem,
@ -136,42 +252,37 @@ 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<DialogParams?>(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,
loadingState = loading,
playlist = playlist,
items = items,
focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
itemId = destination.itemId,
startIndex = index,
shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
itemId = destination.itemId,
startIndex = 0,
shuffle = shuffle,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
@ -183,31 +294,33 @@ fun PlaylistDetails(
items =
listOf(
DialogItem(
context.getString(R.string.go_to),
goToString,
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(item.destination())
},
DialogItem(
context.getString(R.string.play_from_here),
playFromHereString,
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
itemId = destination.itemId,
startIndex = index,
shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
),
)
},
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<BaseItem?>,
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<FilterValueOption>,
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,22 +370,35 @@ 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),
)
when (loadingState) {
is LoadingState.Error -> {
ErrorMessage(loadingState, modifier)
}
LoadingState.Pending, LoadingState.Loading -> {
LoadingPage(modifier)
}
LoadingState.Success -> {
Column(
modifier =
Modifier
@ -271,12 +406,13 @@ fun PlaylistDetailsContent(
.padding(horizontal = 16.dp),
) {
Text(
text = playlist.name ?: stringResource(R.string.playlist),
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 =
@ -290,7 +426,11 @@ fun PlaylistDetailsContent(
.surfaceColorAtElevation(1.dp)
.copy(alpha = .75f),
shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester)
).focusProperties {
onExit = {
playButtonFocusRequester.tryRequestFocus()
}
}.focusRequester(focusRequester)
.focusGroup()
.focusRestorer(focus),
) {
@ -327,6 +467,28 @@ fun PlaylistDetailsContent(
)
}
}
} 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<FilterValueOption>,
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,

View file

@ -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<FilterValueOption> =
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<Int>()
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()
}
}
}