mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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  ## AI or LLM usage None
This commit is contained in:
parent
ae5035703f
commit
045b689994
5 changed files with 497 additions and 246 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue