Add filtering & sorting for discover grid pages (#1614)

## Description
This PR adds sort & filter options for the discover grid pages.

Sort options:
- Popularity
- Release date
- "Community rating" (TMDB user vote)
- Name

Filter options:
- Genre
- Studio/Network
- "Community rating" (TMDB user vote)
- Parental rating
- Production status (TV only)

Also, two more tabs are added to the discover page for browsing movies
and TV. These are essentially the same as clicking "View more" on the
respective rows on the Discover tab.

### Limitations

This PR hardcodes the region used for studio listing and filtering to
"United States". I'll work on a follow up PR to enable users to specify
this.

The sort & filters are not persisted. I think they should be on the two
new tabs, but not elsewhere, but this will be a follow up PR.

### Related issues
Related to #702 
Implements some of #1511
Rewrites the filter logic in #1610 

### Testing
Emulator, but needs further testing

## Screenshots
<img width="1280" height="720" alt="discover_filters Large"
src="https://github.com/user-attachments/assets/cdefb95c-c4b7-4e06-9bac-b535d372ba03"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-23 17:24:37 -04:00 • committed by GitHub
parent 552c3b5500
commit d705a3ea01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1200 additions and 157 deletions

View file

@ -0,0 +1,286 @@
package com.github.damontecres.wholphin.data.filter
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.SearchApi
import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet
import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response
import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.ui.data.flip
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.SortOrder
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d")
enum class DiscoverSort(
val key: String,
@StringRes val stringRes: Int,
) {
POPULARITY("popularity", R.string.popularity),
RELEASE_DATE("release_date", R.string.sort_by_date_released),
TMDB_VOTE("vote_average", R.string.community_rating),
ALPHABETICAL("original_title", R.string.sort_by_name),
}
private val SortOrder.key get() =
when (this) {
SortOrder.ASCENDING -> "asc"
SortOrder.DESCENDING -> "desc"
}
@Serializable
data class DiscoverSortAndDirection(
val sort: DiscoverSort = DiscoverSort.POPULARITY,
val direction: SortOrder = SortOrder.DESCENDING,
) {
val key = "${sort.key}.${direction.key}"
fun flip() = copy(direction = direction.flip())
}
@Serializable
data class DiscoverFilter(
// /api/v1/languages
val language: String? = null,
val genreIds: List<Int>? = null,
val networkIds: List<Int>? = null,
// /api/v1/search/keyword
val keywordIds: List<Int>? = null,
val excludeKeywordIds: List<Int>? = null,
val sortBy: DiscoverSortAndDirection? = null,
// val firstAirDateGte: LocalDate? = null,
// val firstAirDateLte: LocalDate? = null,
val withRuntimeGte: Duration? = null,
val withRuntimeLte: Duration? = null,
val voteAverageGte: Int? = null,
val voteAverageLte: Int? = null,
val voteCountGte: Int? = null,
val voteCountLte: Int? = null,
// /api/v1/watchproviders/regions
val watchRegion: String? = null, // US
// networkIds?
// val watchProviders: String? = null,
val status: List<TvDetails.Status>? = null,
val certification: List<String>? = null,
// val certificationGte: String? = null,
// val certificationLte: String? = null,
// val certificationCountry: String? = null, // US
// val certificationMatchExact: Boolean? = null,
) {
/**
* Returns how many of filters are actually being used in this
*/
fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter)
}
return newFilter
}
suspend fun discoverTv(
searchApi: SearchApi,
page: Int,
): DiscoverTvGet200Response =
searchApi.discoverTvGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key,
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
status = status?.joinToString("|") { it.ordinal.toString() },
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT },
)
suspend fun discoverMovies(
searchApi: SearchApi,
page: Int,
): DiscoverMoviesGet200Response =
searchApi.discoverMoviesGet(
page = page,
language = language,
genre = genreIds?.joinToString(",") { it.toString() },
// network = null,
keywords = keywordIds?.joinToString(",") { it.toString() },
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
sortBy = sortBy?.key,
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
voteAverageGte = voteAverageGte,
voteAverageLte = voteAverageLte,
voteCountGte = voteCountGte,
voteCountLte = voteCountLte,
// TODO
// watchRegion = watchRegion,
watchRegion = networkIds?.let { "US" },
watchProviders = networkIds?.joinToString(",") { it.toString() },
// status = status,
certification = certification?.joinToString("|"),
// certificationGte = certificationGte,
// certificationLte = certificationLte,
certificationCountry = certification?.let { "US" },
certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT },
)
}
val discoverMovieFilters =
listOf(
DiscoverMovieGenreFilter,
DiscoverMovieStudiosFilter,
DiscoverRatingFilter,
DiscoverMovieContentRatingFilter,
)
val discoverTvFilters =
listOf(
DiscoverTvGenreFilter,
DiscoverTvStudiosFilter,
DiscoverRatingFilter,
DiscoverTvContentRatingFilter,
DiscoverTvStatusFilter,
)
/**
* A way to filter discover
*
* Gets and sets values within a [DiscoverFilter]
*/
sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T>
data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(genreIds = value)
}
data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
override fun set(
value: List<Int>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(networkIds = value)
}
data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvDetails.Status>> {
override val stringRes: Int = R.string.production_status
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<TvDetails.Status>? = filter.status
override fun set(
value: List<TvDetails.Status>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(status = value)
}
data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
override fun set(
value: List<String>?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(certification = value)
}
data object DiscoverRatingFilter : DiscoverFilterBy<Int> {
override val stringRes: Int = R.string.community_rating
override val supportMultiple: Boolean = true
override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte
override fun set(
value: Int?,
filter: DiscoverFilter,
): DiscoverFilter = filter.copy(voteAverageGte = value)
}

View file

@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions =
) )
/** /**
* A way to filter libraries * Abstract way to filter data
*
* Gets and sets values within a [GetItemsFilter]
*/ */
sealed interface ItemFilterBy<T> { sealed interface FilterBy<FilterType, DataType> {
@get:StringRes @get:StringRes
val stringRes: Int val stringRes: Int
val supportMultiple: Boolean val supportMultiple: Boolean
fun get(filter: GetItemsFilter): T? fun get(filter: FilterType): DataType?
fun set( fun set(
value: T?, value: DataType?,
filter: GetItemsFilter, filter: FilterType,
): GetItemsFilter ): FilterType
} }
/**
* A way to filter jellyfin libraries
*
* Gets and sets values within a [GetItemsFilter]
*/
sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T>
data object GenreFilter : ItemFilterBy<List<UUID>> { data object GenreFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.genres override val stringRes: Int = R.string.genres

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.data.filter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
interface SearchFilter<T : SearchFilter<T>> {
/**
* Returns how many of filters are actually being used in this [GetItemsFilter]
*/
fun countFilters(filterOptions: List<FilterBy<T, *>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this as T) != null) count++
}
return count
}
/**
* Clear all the values for the given filters
*/
fun delete(filterOptions: List<FilterBy<T, *>>): T {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter as T)
}
return newFilter as T
}
}

View file

@ -1,14 +1,24 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter
import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterBy
import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter 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.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.StudioFilter import com.github.damontecres.wholphin.data.filter.StudioFilter
@ -20,6 +30,7 @@ import com.mayakapps.kache.InMemoryKache
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.filterApi import org.jellyfin.sdk.api.client.extensions.filterApi
import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.genresApi
@ -47,6 +58,7 @@ class FilterOptionCache
@param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val seerrService: SeerrService,
) { ) {
private val cache = private val cache =
InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) { InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) {
@ -61,7 +73,7 @@ class FilterOptionCache
*/ */
suspend fun getFilterOptionValues( suspend fun getFilterOptionValues(
parentId: UUID?, parentId: UUID?,
filterOption: ItemFilterBy<*>, filterOption: FilterBy<*, *>,
): List<FilterValueOption> { ): List<FilterValueOption> {
val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption)
return try { return try {
@ -81,7 +93,7 @@ class FilterOptionCache
private suspend fun getFilterOptionValues( private suspend fun getFilterOptionValues(
userId: UUID?, userId: UUID?,
parentId: UUID?, parentId: UUID?,
filterOption: ItemFilterBy<*>, filterOption: FilterBy<*, *>,
) = when (filterOption) { ) = when (filterOption) {
GenreFilter -> { GenreFilter -> {
api.genresApi api.genresApi
@ -173,11 +185,86 @@ class FilterOptionCache
FilterValueOption("$it", it) FilterValueOption("$it", it)
} }
} }
is DiscoverFilterBy -> {
getDiscoverFilterOptionValues(filterOption)
}
}
private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> {
if (seerrService.active.firstOrNull() != true) {
return emptyList()
}
return when (filterOption) {
DiscoverMovieGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderMovieGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvGenreFilter -> {
seerrService.api.searchApi
.discoverGenresliderTvGet()
.filter { it.name != null && it.id != null }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverMovieStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersMoviesGet("US")
.filter { it.name != null && it.id != null }
// TODO not what this is for
// .sortedBy { it.displayPriority }
.map {
// TODO logo?
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStudiosFilter -> {
// TODO region
seerrService.api.otherApi
.watchprovidersTvGet("US")
.filter { it.name != null && it.id != null }
// .sortedBy { it.displayPriority }
.map {
FilterValueOption(it.name!!, it.id!!)
}
}
DiscoverTvStatusFilter -> {
TvDetails.Status.entries.map {
FilterValueOption(it.value, it)
}
}
DiscoverMovieContentRatingFilter -> {
listOf("NR", "G", "PG", "PG-13", "R", "NC-17")
.map { FilterValueOption(it, it) }
}
DiscoverTvContentRatingFilter -> {
listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA")
.map { FilterValueOption(it, it) }
}
DiscoverRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
} }
private data class FilterOptionCacheKey( private data class FilterOptionCacheKey(
val userId: UUID?, val userId: UUID?,
val parentId: UUID?, val parentId: UUID?,
val filterOption: ItemFilterBy<*>, val filterOption: FilterBy<*, *>,
) )
} }

View file

@ -37,8 +37,19 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter
import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter
import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType import com.github.damontecres.wholphin.data.filter.FilterVideoType
@ -427,6 +438,286 @@ fun ExpandableFilterButton(
} }
} }
// TODO this all duplicated code from FilterByButton
@Composable
fun DiscoverFilterByButton(
filterOptions: List<DiscoverFilterBy<*>>,
current: DiscoverFilter,
onFilterChange: (DiscoverFilter) -> Unit,
getPossibleValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>,
modifier: Modifier = Modifier,
onShow: ((Boolean) -> Unit)? = null,
) {
var dropDown by remember { mutableStateOf(false) }
var nestedDropDown by remember { mutableStateOf<DiscoverFilterBy<*>?>(null) }
val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) }
Box(modifier = modifier) {
ExpandableFilterButton(
filterCount = filterCount,
onClick = {
onShow?.invoke(true)
dropDown = true
},
modifier = Modifier,
)
DropdownMenu(
expanded = dropDown,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
onDismissRequest = {
onShow?.invoke(false)
dropDown = false
nestedDropDown = null
},
) {
if (filterCount > 0) {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
TvDropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = Color.Red.copy(alpha = .8f),
)
},
text = {
Text(
text = stringResource(R.string.remove),
color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error,
)
},
onClick = {
onFilterChange.invoke(current.delete(filterOptions))
dropDown = false
},
interactionSource = interactionSource,
modifier = Modifier,
)
HorizontalDivider()
}
filterOptions
.forEachIndexed { index, filterOption ->
val focusRequester = remember { FocusRequester() }
if (index == 0) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
val currentValue = remember(current) { filterOption.get(current) }
TvDropdownMenuItem(
leadingIcon = {
if (currentValue != null) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Filter active",
)
}
},
text = {
Text(
text = stringResource(filterOption.stringRes),
)
},
onClick = {
nestedDropDown = filterOption
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
DropdownMenu(
expanded = nestedDropDown != null,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp),
offset = DpOffset(80.dp, 16.dp),
onDismissRequest = {
nestedDropDown = null
},
) {
nestedDropDown?.let { filterOption ->
filterOption as DiscoverFilterBy<Any>
val currentValue = remember(current) { filterOption.get(current) }
var possibleValues by remember { mutableStateOf<List<FilterValueOption>?>(null) }
LaunchedEffect(Unit) {
possibleValues = getPossibleValues.invoke(filterOption)
}
if (currentValue != null) {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
TvDropdownMenuItem(
elevation = 5.dp,
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = Color.Red.copy(alpha = .8f),
)
},
text = {
Text(
text = stringResource(R.string.remove),
color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error,
)
},
onClick = {
onFilterChange.invoke(filterOption.set(null, current))
nestedDropDown = null
},
interactionSource = interactionSource,
modifier = Modifier,
)
HorizontalDivider()
}
if (possibleValues == null) {
CircularProgress(Modifier.size(48.dp))
} else if (possibleValues?.isEmpty() == true) {
Text(
text = stringResource(R.string.no_results),
)
} else {
possibleValues.orEmpty().forEachIndexed { index, value ->
val focusRequester = remember { FocusRequester() }
if (index == 0) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
val isSelected =
remember(currentValue) {
when (filterOption) {
DiscoverMovieGenreFilter,
DiscoverTvGenreFilter,
DiscoverMovieStudiosFilter,
DiscoverTvStudiosFilter,
-> {
(currentValue as? List<Int>)
.orEmpty()
.contains(value.value)
}
DiscoverTvStatusFilter -> {
(currentValue as? List<TvDetails.Status>)
.orEmpty()
.contains(value.value)
}
DiscoverMovieContentRatingFilter,
DiscoverTvContentRatingFilter,
-> {
(currentValue as? List<String>)
.orEmpty()
.contains(value.value)
}
DiscoverRatingFilter -> {
(currentValue as? Int) == value.value
}
}
}
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
TvDropdownMenuItem(
elevation = 8.dp,
interactionSource = interactionSource,
leadingIcon = {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Filter active",
)
}
},
text = {
if (filterOption == DiscoverRatingFilter) {
SimpleStarRating(
"${value.name}+",
starColor = if (focused) EmptyStarColor else FilledStarColor,
)
} else {
Text(
text = value.name,
)
}
},
onClick = {
val newFilter =
when (filterOption) {
DiscoverMovieGenreFilter,
DiscoverTvGenreFilter,
DiscoverMovieStudiosFilter,
DiscoverTvStudiosFilter,
-> {
val list = (currentValue as? List<Int>).orEmpty()
val newValue =
list
.toMutableList()
.apply {
if (isSelected) {
remove(value.value!!)
} else {
add(value.value!! as Int)
}
}.takeIf { it.isNotEmpty() }
filterOption.set(newValue, current)
}
DiscoverTvStatusFilter -> {
val list =
(currentValue as? List<TvDetails.Status>).orEmpty()
val newValue =
list
.toMutableList()
.apply {
if (isSelected) {
remove(value.value!!)
} else {
add(value.value!! as TvDetails.Status)
}
}.takeIf { it.isNotEmpty() }
filterOption.set(newValue, current)
}
DiscoverMovieContentRatingFilter,
DiscoverTvContentRatingFilter,
-> {
val list = (currentValue as? List<String>).orEmpty()
val newValue =
list
.toMutableList()
.apply {
if (isSelected) {
remove(value.value!!)
} else {
add(value.value!! as String)
}
}.takeIf { it.isNotEmpty() }
filterOption.set(newValue, current)
}
DiscoverRatingFilter -> {
filterOption.set(
value.value as? Int,
current,
)
}
}
onFilterChange.invoke(newFilter)
if (!filterOption.supportMultiple) {
nestedDropDown = null
}
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
}
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun ExpandableFilterButtonPreview() { private fun ExpandableFilterButtonPreview() {

View file

@ -14,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DiscoverSort
import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.flip import com.github.damontecres.wholphin.ui.data.flip
@ -115,3 +117,94 @@ fun SortAndDirection.toAnnotatedString() =
append(" ") append(" ")
append(stringResource(getStringRes(sort))) append(stringResource(getStringRes(sort)))
} }
// TODO code duplicated from SortByButton
@Composable
fun DiscoverSortByButton(
sortOptions: List<DiscoverSort>,
current: DiscoverSortAndDirection,
onSortChange: (DiscoverSortAndDirection) -> Unit,
modifier: Modifier = Modifier,
) {
val currentSort = current.sort
val currentDirection = current.direction
var sortByDropDown by remember { mutableStateOf(false) }
Box(modifier = modifier) {
TextButton(
onClick = { sortByDropDown = true },
onLongClick = {
onSortChange.invoke(current.flip())
},
) {
Text(
text = current.toAnnotatedString(),
)
}
DropdownMenu(
expanded = sortByDropDown,
onDismissRequest = { sortByDropDown = false },
) {
sortOptions
// .sortedBy { it.name }
.forEach { sortOption ->
TvDropdownMenuItem(
leadingIcon = {
if (sortOption == currentSort) {
if (currentDirection == SortOrder.ASCENDING) {
Text(
text = stringResource(R.string.fa_caret_up),
fontFamily = FontAwesome,
)
} else {
Text(
text = stringResource(R.string.fa_caret_down),
fontFamily = FontAwesome,
)
}
}
},
text = {
Text(
text = stringResource(sortOption.stringRes),
)
},
onClick = {
sortByDropDown = false
val newDirection =
if (currentSort == sortOption) {
currentDirection.flip()
} else {
currentDirection
}
onSortChange.invoke(
DiscoverSortAndDirection(
sortOption,
newDirection,
),
)
},
)
}
}
}
}
@Composable
fun DiscoverSortAndDirection.toAnnotatedString() =
buildAnnotatedString {
withStyle(SpanStyle(fontFamily = FontAwesome)) {
append(
stringResource(
if (direction == SortOrder.ASCENDING) {
R.string.fa_caret_up
} else {
R.string.fa_caret_down
},
),
)
}
append(" ")
append(stringResource(sort.stringRes))
}

View file

@ -0,0 +1,104 @@
package com.github.damontecres.wholphin.ui.discover
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverSort
import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.ui.components.DiscoverFilterByButton
import com.github.damontecres.wholphin.ui.components.DiscoverSortByButton
import com.github.damontecres.wholphin.ui.components.GridTitle
@Composable
fun DiscoverGridHeader(
showHeader: Boolean,
showTitle: Boolean,
title: String,
sortAndDirection: DiscoverSortAndDirection,
onSortChange: (DiscoverSortAndDirection) -> Unit,
sortOptions: List<DiscoverSort>,
getPossibleFilterValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>,
onClickShowViewOptions: () -> Unit,
modifier: Modifier = Modifier,
currentFilter: DiscoverFilter = DiscoverFilter(),
filterOptions: List<DiscoverFilterBy<*>> = listOf(),
onFilterChange: (DiscoverFilter) -> Unit = {},
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
) {
AnimatedVisibility(
showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (showTitle) {
GridTitle(title)
}
val endPadding =
16.dp // + if (sortAndDirection.sort == DiscoverSort.ALPHABETICAL) 24.dp else 0.dp
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.focusRestorer()
.fillMaxWidth(),
) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusRestorer(),
) {
if (sortOptions.isNotEmpty()) {
DiscoverSortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
if (filterOptions.isNotEmpty()) {
DiscoverFilterByButton(
filterOptions = filterOptions,
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier = Modifier.focusRequester(filterButtonFocusRequester),
onShow = onShowFilterDropdown,
)
}
// ExpandableFaButton(
// title = R.string.view_options,
// iconStringRes = R.string.fa_sliders,
// onClick = onClickShowViewOptions,
// modifier = Modifier,
// )
}
}
}
}
}
}

View file

@ -14,7 +14,9 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.util.DiscoverRequestType
@Composable @Composable
fun DiscoverPage( fun DiscoverPage(
@ -27,6 +29,8 @@ fun DiscoverPage(
TabDetails(R.string.discover), TabDetails(R.string.discover),
TabDetails(R.string.request), TabDetails(R.string.request),
TabDetails(R.string.search), TabDetails(R.string.search),
TabDetails(R.string.movies_title),
TabDetails(R.string.tv_shows_title),
) )
} }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
@ -74,6 +78,42 @@ fun DiscoverPage(
) )
} }
// Movies
3 -> {
DiscoverRequestGrid(
viewModelKey = "movies",
showTitle = false,
destination =
Destination.DiscoverMoreResult(
DiscoverRequestType.DISCOVER_MOVIES,
0,
),
positionCallback = { columns, index -> showHeader = index < columns },
modifier =
Modifier
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
)
}
// TV
4 -> {
DiscoverRequestGrid(
viewModelKey = "tv",
showTitle = false,
destination =
Destination.DiscoverMoreResult(
DiscoverRequestType.DISCOVER_TV,
0,
),
positionCallback = { columns, index -> showHeader = index < columns },
modifier =
Modifier
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
)
}
else -> { else -> {
ErrorMessage("Invalid tab index $tabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }

View file

@ -1,26 +1,39 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
import com.github.damontecres.wholphin.data.filter.DiscoverSort
import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.discoverMovieFilters
import com.github.damontecres.wholphin.data.filter.discoverTvFilters
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridTitle
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -28,7 +41,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler
import com.github.damontecres.wholphin.util.DiscoverPagerType
import com.github.damontecres.wholphin.util.DiscoverRequestPager import com.github.damontecres.wholphin.util.DiscoverRequestPager
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler
@ -40,6 +52,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -53,14 +67,17 @@ class DiscoverRequestViewModel
private val seerrService: SeerrService, private val seerrService: SeerrService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val api: SeerrApi, private val api: SeerrApi,
@Assisted val type: DiscoverPagerType, private val filterOptionCache: FilterOptionCache,
@Assisted val type: DiscoverRequestType,
@Assisted startIndex: Int, @Assisted startIndex: Int,
@Assisted initialFilter: DiscoverFilter,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
type: DiscoverPagerType, type: DiscoverRequestType,
startIndex: Int, startIndex: Int,
initialFilter: DiscoverFilter,
): DiscoverRequestViewModel ): DiscoverRequestViewModel
} }
@ -69,15 +86,68 @@ class DiscoverRequestViewModel
init { init {
viewModelScope.launchDefault { viewModelScope.launchDefault {
val (sortOptions, potentialFilterOptions) =
when (type) {
DiscoverRequestType.DISCOVER_TV -> DiscoverSort.entries.toList() to discoverTvFilters
DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSort.entries.toList() to discoverMovieFilters
DiscoverRequestType.TRENDING -> emptyList<DiscoverSort>() to emptyList()
DiscoverRequestType.UPCOMING_TV -> emptyList<DiscoverSort>() to emptyList()
DiscoverRequestType.UPCOMING_MOVIES -> emptyList<DiscoverSort>() to emptyList()
DiscoverRequestType.UNKNOWN -> emptyList<DiscoverSort>() to emptyList()
}
val sortAndDirection =
when (type) {
DiscoverRequestType.DISCOVER_TV -> DiscoverSortAndDirection()
DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSortAndDirection()
DiscoverRequestType.TRENDING -> DiscoverSortAndDirection()
DiscoverRequestType.UPCOMING_TV -> DiscoverSortAndDirection()
DiscoverRequestType.UPCOMING_MOVIES -> DiscoverSortAndDirection()
DiscoverRequestType.UNKNOWN -> DiscoverSortAndDirection()
}
val filterOptions =
if (potentialFilterOptions.isNotEmpty()) {
// If the initial filter includes filtering (eg genres), then don't allow for changing it
potentialFilterOptions.filter { it.get(initialFilter) == null }
} else {
potentialFilterOptions
}
_state.update {
it.copy(
startIndex = startIndex,
sortOptions = sortOptions,
filterOptions = filterOptions,
sortAndDirection = sortAndDirection,
)
}
fetchData(sortAndDirection, initialFilter, startIndex)
}
}
private var fetchJob: Job? = null
private suspend fun fetchData(
sortAndDirection: DiscoverSortAndDirection,
filter: DiscoverFilter,
startIndex: Int = 0,
) {
fetchJob?.cancel()
try { try {
_state.update {
it.copy(
filter = filter,
sortAndDirection = sortAndDirection,
loading = DataLoadingState.Loading,
)
}
val filter = filter.copy(sortBy = sortAndDirection)
fetchJob =
viewModelScope.launchDefault {
val pager = val pager =
when (type) { when (type) {
is DiscoverPagerType.RequestType -> {
when (type.type) {
DiscoverRequestType.DISCOVER_TV -> { DiscoverRequestType.DISCOVER_TV -> {
DiscoverRequestPager( DiscoverRequestPager(
api, api,
DiscoverTvRequestHandler(), DiscoverTvRequestHandler(filter),
seerrService::createDiscoverItem, seerrService::createDiscoverItem,
viewModelScope, viewModelScope,
) )
@ -86,7 +156,7 @@ class DiscoverRequestViewModel
DiscoverRequestType.DISCOVER_MOVIES -> { DiscoverRequestType.DISCOVER_MOVIES -> {
DiscoverRequestPager( DiscoverRequestPager(
api, api,
DiscoverMovieRequestHandler(), DiscoverMovieRequestHandler(filter),
seerrService::createDiscoverItem, seerrService::createDiscoverItem,
viewModelScope, viewModelScope,
) )
@ -122,40 +192,15 @@ class DiscoverRequestViewModel
DiscoverRequestType.UNKNOWN -> { DiscoverRequestType.UNKNOWN -> {
throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN")
} }
}
}
is DiscoverPagerType.Genre -> {
when (type.type) {
SeerrItemType.MOVIE -> {
DiscoverRequestPager(
api,
DiscoverMovieRequestHandler(genre = type.genreId),
seerrService::createDiscoverItem,
viewModelScope,
)
}
SeerrItemType.TV -> {
DiscoverRequestPager(
api,
DiscoverTvRequestHandler(genre = type.genreId),
seerrService::createDiscoverItem,
viewModelScope,
)
}
else -> {
throw UnsupportedOperationException("Type not supported: ${type.type}")
}
}
}
}.init(startIndex) }.init(startIndex)
_state.update { _state.update {
it.copy( it.copy(
loading = DataLoadingState.Success(pager), loading = DataLoadingState.Success(pager),
) )
} }
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error initializing %s", type) Timber.e(ex, "Error initializing %s", type)
_state.update { _state.update {
@ -165,44 +210,92 @@ class DiscoverRequestViewModel
} }
} }
} }
fun onSortChange(newSort: DiscoverSortAndDirection) {
viewModelScope.launchDefault {
_state.update { it.copy(startIndex = 0) }
fetchData(newSort, state.value.filter)
} }
} }
fun onFilterChange(newFilter: DiscoverFilter) {
viewModelScope.launchDefault {
_state.update { it.copy(startIndex = 0) }
fetchData(state.value.sortAndDirection, newFilter)
}
}
suspend fun getPossibleFilterValues(filter: DiscoverFilterBy<*>): List<FilterValueOption> =
filterOptionCache.getFilterOptionValues(null, filter)
}
data class DiscoverRequestState( data class DiscoverRequestState(
val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending, val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending,
val startIndex: Int = 0,
val sortOptions: List<DiscoverSort> = emptyList(),
val filterOptions: List<DiscoverFilterBy<*>> = emptyList(),
val filter: DiscoverFilter = DiscoverFilter(),
val sortAndDirection: DiscoverSortAndDirection = DiscoverSortAndDirection(),
) )
@Composable @Composable
fun DiscoverRequestGrid( fun DiscoverRequestGrid(
destination: Destination.DiscoverMoreResult, destination: Destination.DiscoverMoreResult,
showTitle: Boolean,
positionCallback: (columns: Int, position: Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModelKey: String? = null,
viewModel: DiscoverRequestViewModel = viewModel: DiscoverRequestViewModel =
hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>( hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>(
creationCallback = { it.create(destination.type, destination.startIndex) }, key = viewModelKey,
creationCallback = {
it.create(
destination.type,
destination.startIndex,
destination.initialFilter,
)
},
), ),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var showHeader by remember { mutableStateOf(state.startIndex < 6) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
DiscoverGridHeader(
showHeader = showHeader,
showTitle = showTitle,
title =
destination.titleOverride?.getString()
?: stringResource(destination.type.stringRes),
sortAndDirection = state.sortAndDirection,
onSortChange = viewModel::onSortChange,
sortOptions = state.sortOptions,
getPossibleFilterValues = viewModel::getPossibleFilterValues,
onClickShowViewOptions = {},
currentFilter = state.filter,
filterOptions = state.filterOptions,
onFilterChange = viewModel::onFilterChange,
modifier = Modifier.fillMaxWidth(),
)
when (val s = state.loading) { when (val s = state.loading) {
DataLoadingState.Pending, DataLoadingState.Pending,
DataLoadingState.Loading, DataLoadingState.Loading,
-> { -> {
LoadingPage(modifier) LoadingPage(Modifier)
} }
is DataLoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(s, modifier) ErrorMessage(s, Modifier)
} }
is DataLoadingState.Success<List<DiscoverItem?>> -> { is DataLoadingState.Success<List<DiscoverItem?>> -> {
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
GridTitle(destination.type.title.getString())
CardGrid( CardGrid(
initialPosition = destination.startIndex, initialPosition = state.startIndex,
pager = s.data, pager = s.data,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
@ -222,6 +315,12 @@ fun DiscoverRequestGrid(
width = Dp.Unspecified, width = Dp.Unspecified,
) )
}, },
columns = 6,
positionCallback = { columns, index ->
showHeader = index < columns
positionCallback.invoke(columns, index)
},
modifier = Modifier.fillMaxSize(),
) )
} }
} }

View file

@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.data.model.QuickDetailsData
@ -53,7 +54,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverPagerType
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
@ -495,7 +495,9 @@ fun SeerrDiscoverPage(
positionCallback.invoke(position) positionCallback.invoke(position)
} }
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.DiscoverMoreResult(DiscoverPagerType.RequestType(row.type)), Destination.DiscoverMoreResult(
type = row.type,
),
) )
}, },
modifier = modifier =
@ -534,11 +536,15 @@ fun SeerrDiscoverPage(
position = RowColumn(ROW_GENRES_MOVIE, index) position = RowColumn(ROW_GENRES_MOVIE, index)
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.DiscoverMoreResult( Destination.DiscoverMoreResult(
type = type = DiscoverRequestType.DISCOVER_MOVIES,
DiscoverPagerType.Genre( initialFilter =
genreId = genre.id.toInt(), DiscoverFilter(
name = genre.name, genreIds = listOf(genre.id.toInt()),
type = SeerrItemType.MOVIE, ),
titleOverride =
discoverGenreTitle(
genre.name,
SeerrItemType.MOVIE,
), ),
startIndex = 0, startIndex = 0,
), ),
@ -571,11 +577,15 @@ fun SeerrDiscoverPage(
position = RowColumn(ROW_GENRES_TV, index) position = RowColumn(ROW_GENRES_TV, index)
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.DiscoverMoreResult( Destination.DiscoverMoreResult(
type = type = DiscoverRequestType.DISCOVER_TV,
DiscoverPagerType.Genre( initialFilter =
genreId = genre.id.toInt(), DiscoverFilter(
name = genre.name, genreIds = listOf(genre.id.toInt()),
type = SeerrItemType.TV, ),
titleOverride =
discoverGenreTitle(
genre.name,
SeerrItemType.TV,
), ),
startIndex = 0, startIndex = 0,
), ),

View file

@ -3,6 +3,7 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
@ -14,7 +15,7 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.RequestHandler
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
@ -148,8 +149,10 @@ sealed class Destination(
@Serializable @Serializable
data class DiscoverMoreResult( data class DiscoverMoreResult(
val type: DiscoverPagerType, val type: DiscoverRequestType,
val startIndex: Int = SEERR_PAGE_SIZE, val startIndex: Int = SEERR_PAGE_SIZE,
val initialFilter: DiscoverFilter = DiscoverFilter(),
val titleOverride: StringProvider? = null,
) : Destination(false) ) : Destination(false)
@Serializable @Serializable

View file

@ -405,6 +405,8 @@ fun DestinationContent(
LaunchedEffect(Unit) { onClearBackdrop.invoke() } LaunchedEffect(Unit) { onClearBackdrop.invoke() }
DiscoverRequestGrid( DiscoverRequestGrid(
destination = destination, destination = destination,
showTitle = true,
positionCallback = { _, _ -> },
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
@ -82,33 +83,25 @@ interface DiscoverRequestHandler<T> {
} }
class DiscoverTvRequestHandler( class DiscoverTvRequestHandler(
private val genre: Int? = null, val filter: DiscoverFilter = DiscoverFilter(),
) : DiscoverRequestHandler<TvResult> { ) : DiscoverRequestHandler<TvResult> {
override suspend fun execute( override suspend fun execute(
api: SeerrApi, api: SeerrApi,
pageNumber: Int, pageNumber: Int,
): QueryResult<TvResult> = ): QueryResult<TvResult> =
api.api.searchApi filter.discoverTv(api.api.searchApi, pageNumber).let {
.discoverTvGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0) QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
} }
} }
class DiscoverMovieRequestHandler( class DiscoverMovieRequestHandler(
private val genre: Int? = null, val filter: DiscoverFilter = DiscoverFilter(),
) : DiscoverRequestHandler<MovieResult> { ) : DiscoverRequestHandler<MovieResult> {
override suspend fun execute( override suspend fun execute(
api: SeerrApi, api: SeerrApi,
pageNumber: Int, pageNumber: Int,
): QueryResult<MovieResult> = ): QueryResult<MovieResult> =
api.api.searchApi filter.discoverMovies(api.api.searchApi, pageNumber).let {
.discoverMoviesGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0) QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
} }
} }

View file

@ -826,4 +826,6 @@
<string name="quality_profile">Quality profile</string> <string name="quality_profile">Quality profile</string>
<string name="is_default">Default</string> <string name="is_default">Default</string>
<string name="root_folder">Root folder</string> <string name="root_folder">Root folder</string>
<string name="popularity">Popularity</string>
<string name="production_status">Production status</string>
</resources> </resources>

View file

@ -1199,6 +1199,7 @@ components:
$ref: '#/components/schemas/Season' $ref: '#/components/schemas/Season'
status: status:
type: string type: string
enum: ["Returning Series", "Planned", "In Production", "Ended", "Cancelled", "Pilot"]
tagline: tagline:
type: string type: string
type: type: