mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-06 18:54:49 +01:00
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:
parent
552c3b5500
commit
d705a3ea01
15 changed files with 1200 additions and 157 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<*, *>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue