Date: Fri, 30 Jan 2026 21:16:39 +0000
Subject: [PATCH 098/103] Fix Seerr Local/Jellyfin authentication and request
permissions (#734)
## Description
When using LOCAL or JELLYFIN authentication methods to connect to Seerr,
the login flow was not properly establishing session cookies and user
permissions. The login would 'succeed' and you were led to thinking it
worked, but then...
This caused:
- Request functionality to be greyed out even though the user should
have access
- Users to appear to have no permissions after login (permissions=0)
## Solution
This PR fixes the authentication flow by:
**Fetching full user config after login**: For LOCAL and JELLYFIN
auth methods, the login endpoints (`authLocalPost()` and
`authJellyfinPost()`) establish the session cookie but return incomplete
user data. This PR now calls `authMeGet()` immediately after login to
fetch the fully-populated user configuration including proper
permissions.
## Tests
- Have working apk on my dev fork.
- Ran logins as Local + API. Cannot test Jellyfin due to environment,
but codewise, it looks correct.
## Related issues
Related to https://github.com/damontecres/Wholphin/issues/747
---------
Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com>
---
.../damontecres/wholphin/services/SeerrServerRepository.kt | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index 5d1ce4d3..f06a7529 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -196,6 +196,7 @@ private suspend fun login(
password = password ?: "",
),
)
+ client.usersApi.authMeGet()
}
SeerrAuthMethod.JELLYFIN -> {
@@ -205,6 +206,7 @@ private suspend fun login(
password = password ?: "",
),
)
+ client.usersApi.authMeGet()
}
SeerrAuthMethod.API_KEY -> {
From 510ca416f3d78b692b72f13d5cc9bab1a7d07ed3 Mon Sep 17 00:00:00 2001
From: Damontecres
Date: Fri, 30 Jan 2026 17:10:44 -0500
Subject: [PATCH 099/103] Release v0.4.1
From 7aa4ccc447f4e404059b8cf57a3c9f96173fed3b Mon Sep 17 00:00:00 2001
From: Ray <154766448+damontecres@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:28:41 -0500
Subject: [PATCH 100/103] Update README
Removed versioning details and simplified media player options.
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 50f01c12..b4121cb9 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin.
-This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
+This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.
From e6ff173b41417a18aca56bd6af8c826966719bc7 Mon Sep 17 00:00:00 2001
From: Damontecres
Date: Mon, 2 Feb 2026 10:34:30 -0500
Subject: [PATCH 101/103] Adopt jellyfin's AI/LLM policy
---
.github/pull_request_template.md | 13 +++++++++----
CONTRIBUTING.md | 7 +++++++
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index eae1ff9c..3043d994 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,12 +1,17 @@
+
+
## Description
### Related issues
-
+
-### Screenshots
+### Testing
+
+
+## Screenshots
-### AI/LLM usage
-
+## AI or LLM usage
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9c8484fa..3eee4ee9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any
Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
See the [developer's guide](./DEVELOPMENT.md) for more information.
+
+
+#### Policy for AI or LLMs
+
+Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words.
+
+At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance.
From ae5035703fd92ed1a6fdcd20da5907516711ac6a Mon Sep 17 00:00:00 2001
From: Ray <154766448+damontecres@users.noreply.github.com>
Date: Mon, 2 Feb 2026 12:25:16 -0500
Subject: [PATCH 102/103] Merge pull request #817 from damontecres/fix/805
Always use space for home page header
---
.../damontecres/wholphin/ui/main/HomePage.kt | 28 +++++++++----------
1 file changed, 13 insertions(+), 15 deletions(-)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index d866926d..4c58b00c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -401,19 +401,17 @@ fun HomePageHeader(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
- item?.let {
- val isEpisode = item.type == BaseItemKind.EPISODE
- val dto = item.data
- HomePageHeader(
- title = item.title,
- subtitle = if (isEpisode) dto.name else null,
- overview = dto.overview,
- overviewTwoLines = isEpisode,
- quickDetails = item.ui.quickDetails,
- timeRemaining = item.timeRemainingOrRuntime,
- modifier = modifier,
- )
- }
+ val isEpisode = item?.type == BaseItemKind.EPISODE
+ val dto = item?.data
+ HomePageHeader(
+ title = item?.title,
+ subtitle = if (isEpisode) dto?.name else null,
+ overview = dto?.overview,
+ overviewTwoLines = isEpisode,
+ quickDetails = item?.ui?.quickDetails,
+ timeRemaining = item?.timeRemainingOrRuntime,
+ modifier = modifier,
+ )
}
@Composable
@@ -422,7 +420,7 @@ fun HomePageHeader(
subtitle: String?,
overview: String?,
overviewTwoLines: Boolean,
- quickDetails: AnnotatedString,
+ quickDetails: AnnotatedString?,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
) {
@@ -450,7 +448,7 @@ fun HomePageHeader(
subtitle?.let {
EpisodeName(it)
}
- QuickDetails(quickDetails, timeRemaining)
+ QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
val overviewModifier =
Modifier
.padding(0.dp)
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 103/103] 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
---
.../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()
+ }
+ }
+}