From d705a3ea01991fd8b66f4a2f33a612d8018bc4e9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Jun 2026 17:24:37 -0400 Subject: [PATCH] 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 discover_filters Large ## AI or LLM usage None --- .../wholphin/data/filter/DiscoverFilter.kt | 286 +++++++++++++++ .../wholphin/data/filter/ItemFilterBy.kt | 21 +- .../wholphin/data/filter/SearchFilter.kt | 27 ++ .../wholphin/services/FilterOptionCache.kt | 95 ++++- .../wholphin/ui/components/FilterByButton.kt | 291 ++++++++++++++++ .../wholphin/ui/components/SortByButton.kt | 93 +++++ .../ui/discover/DiscoverGridHeader.kt | 104 ++++++ .../wholphin/ui/discover/DiscoverPage.kt | 40 +++ .../ui/discover/DiscoverRequestGrid.kt | 329 ++++++++++++------ .../wholphin/ui/discover/SeerrDiscoverPage.kt | 34 +- .../wholphin/ui/nav/Destination.kt | 7 +- .../wholphin/ui/nav/DestinationContent.kt | 2 + .../wholphin/util/DiscoverRequestPager.kt | 25 +- app/src/main/res/values/strings.xml | 2 + app/src/main/seerr/seerr-api.yml | 1 + 15 files changed, 1200 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt new file mode 100644 index 00000000..08863fff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -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? = null, + val networkIds: List? = null, + // /api/v1/search/keyword + val keywordIds: List? = null, + val excludeKeywordIds: List? = 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? = null, + val certification: List? = 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>): 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>): 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 : FilterBy + +data object DiscoverMovieGenreFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.genreIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverTvGenreFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.genreIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverMovieStudiosFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.networkIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStudiosFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.networkIds + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStatusFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.production_status + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.status + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(status = value) +} + +data object DiscoverMovieContentRatingFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.certification + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverTvContentRatingFilter : DiscoverFilterBy> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List? = filter.certification + + override fun set( + value: List?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverRatingFilter : DiscoverFilterBy { + 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) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 37fd9514..b92d251f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions = ) /** - * A way to filter libraries - * - * Gets and sets values within a [GetItemsFilter] + * Abstract way to filter data */ -sealed interface ItemFilterBy { +sealed interface FilterBy { @get:StringRes val stringRes: Int val supportMultiple: Boolean - fun get(filter: GetItemsFilter): T? + fun get(filter: FilterType): DataType? fun set( - value: T?, - filter: GetItemsFilter, - ): GetItemsFilter + value: DataType?, + filter: FilterType, + ): FilterType } +/** + * A way to filter jellyfin libraries + * + * Gets and sets values within a [GetItemsFilter] + */ +sealed interface ItemFilterBy : FilterBy + data object GenreFilter : ItemFilterBy> { override val stringRes: Int = R.string.genres diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt new file mode 100644 index 00000000..6b30e9ff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.data.filter + +import com.github.damontecres.wholphin.data.model.GetItemsFilter + +interface SearchFilter> { + /** + * Returns how many of filters are actually being used in this [GetItemsFilter] + */ + fun countFilters(filterOptions: List>): 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>): T { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter as T) + } + return newFilter as T + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt index 710fbeba..dc055bd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -1,14 +1,24 @@ package com.github.damontecres.wholphin.services 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.filter.CommunityRatingFilter 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.FilterBy import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterVideoType import com.github.damontecres.wholphin.data.filter.GenreFilter -import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter import com.github.damontecres.wholphin.data.filter.StudioFilter @@ -20,6 +30,7 @@ import com.mayakapps.kache.InMemoryKache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.filterApi import org.jellyfin.sdk.api.client.extensions.genresApi @@ -47,6 +58,7 @@ class FilterOptionCache @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, private val api: ApiClient, private val serverRepository: ServerRepository, + private val seerrService: SeerrService, ) { private val cache = InMemoryKache>(16) { @@ -61,7 +73,7 @@ class FilterOptionCache */ suspend fun getFilterOptionValues( parentId: UUID?, - filterOption: ItemFilterBy<*>, + filterOption: FilterBy<*, *>, ): List { val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) return try { @@ -81,7 +93,7 @@ class FilterOptionCache private suspend fun getFilterOptionValues( userId: UUID?, parentId: UUID?, - filterOption: ItemFilterBy<*>, + filterOption: FilterBy<*, *>, ) = when (filterOption) { GenreFilter -> { api.genresApi @@ -173,11 +185,86 @@ class FilterOptionCache FilterValueOption("$it", it) } } + + is DiscoverFilterBy -> { + getDiscoverFilterOptionValues(filterOption) + } + } + + private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List { + 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( val userId: UUID?, val parentId: UUID?, - val filterOption: ItemFilterBy<*>, + val filterOption: FilterBy<*, *>, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index 378c8807..9789d48f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -37,8 +37,19 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter 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.FilterValueOption 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>, + current: DiscoverFilter, + onFilterChange: (DiscoverFilter) -> Unit, + getPossibleValues: suspend (DiscoverFilterBy<*>) -> List, + modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, +) { + var dropDown by remember { mutableStateOf(false) } + var nestedDropDown by remember { mutableStateOf?>(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 + val currentValue = remember(current) { filterOption.get(current) } + + var possibleValues by remember { mutableStateOf?>(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) + .orEmpty() + .contains(value.value) + } + + DiscoverTvStatusFilter -> { + (currentValue as? List) + .orEmpty() + .contains(value.value) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + (currentValue as? List) + .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).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).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).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 @Composable private fun ExpandableFilterButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt index 53511a3a..3e2555db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.tv.material3.Text 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.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.flip @@ -115,3 +117,94 @@ fun SortAndDirection.toAnnotatedString() = append(" ") append(stringResource(getStringRes(sort))) } + +// TODO code duplicated from SortByButton +@Composable +fun DiscoverSortByButton( + sortOptions: List, + 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)) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt new file mode 100644 index 00000000..2064faa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt @@ -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, + getPossibleFilterValues: suspend (DiscoverFilterBy<*>) -> List, + onClickShowViewOptions: () -> Unit, + modifier: Modifier = Modifier, + currentFilter: DiscoverFilter = DiscoverFilter(), + filterOptions: List> = 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, +// ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 3ef3424a..6eff501e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -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.TabDetails 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.util.DiscoverRequestType @Composable fun DiscoverPage( @@ -27,6 +29,8 @@ fun DiscoverPage( TabDetails(R.string.discover), TabDetails(R.string.request), TabDetails(R.string.search), + TabDetails(R.string.movies_title), + TabDetails(R.string.tv_shows_title), ) } 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 -> { ErrorMessage("Invalid tab index $tabIndex", null) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index 08a9efe1..57a43ba4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -1,26 +1,39 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.layout.Arrangement 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel 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.SeerrItemType +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard 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.detail.CardGrid 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.util.DataLoadingState 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.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler @@ -40,6 +52,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -53,14 +67,17 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, - @Assisted val type: DiscoverPagerType, + private val filterOptionCache: FilterOptionCache, + @Assisted val type: DiscoverRequestType, @Assisted startIndex: Int, + @Assisted initialFilter: DiscoverFilter, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - type: DiscoverPagerType, + type: DiscoverRequestType, startIndex: Int, + initialFilter: DiscoverFilter, ): DiscoverRequestViewModel } @@ -69,140 +86,216 @@ class DiscoverRequestViewModel init { viewModelScope.launchDefault { - try { - val pager = - when (type) { - is DiscoverPagerType.RequestType -> { - when (type.type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler(), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler(), - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_TV -> { - DiscoverRequestPager( - api, - UpcomingTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_MOVIES -> { - DiscoverRequestPager( - api, - UpcomingMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - 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) - _state.update { - it.copy( - loading = DataLoadingState.Success(pager), - ) + val (sortOptions, potentialFilterOptions) = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSort.entries.toList() to discoverTvFilters + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSort.entries.toList() to discoverMovieFilters + DiscoverRequestType.TRENDING -> emptyList() to emptyList() + DiscoverRequestType.UPCOMING_TV -> emptyList() to emptyList() + DiscoverRequestType.UPCOMING_MOVIES -> emptyList() to emptyList() + DiscoverRequestType.UNKNOWN -> emptyList() to emptyList() } - } catch (ex: Exception) { - Timber.e(ex, "Error initializing %s", type) - _state.update { - it.copy( - loading = DataLoadingState.Error(ex), - ) + 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 { + _state.update { + it.copy( + filter = filter, + sortAndDirection = sortAndDirection, + loading = DataLoadingState.Loading, + ) + } + val filter = filter.copy(sortBy = sortAndDirection) + fetchJob = + viewModelScope.launchDefault { + val pager = + when (type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + }.init(startIndex) + _state.update { + it.copy( + loading = DataLoadingState.Success(pager), + ) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error initializing %s", type) + _state.update { + it.copy( + loading = DataLoadingState.Error(ex), + ) } } } + + 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 = + filterOptionCache.getFilterOptionValues(null, filter) } data class DiscoverRequestState( val loading: DataLoadingState> = DataLoadingState.Pending, + val startIndex: Int = 0, + val sortOptions: List = emptyList(), + val filterOptions: List> = emptyList(), + val filter: DiscoverFilter = DiscoverFilter(), + val sortAndDirection: DiscoverSortAndDirection = DiscoverSortAndDirection(), ) @Composable fun DiscoverRequestGrid( destination: Destination.DiscoverMoreResult, + showTitle: Boolean, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, + viewModelKey: String? = null, viewModel: DiscoverRequestViewModel = hiltViewModel( - creationCallback = { it.create(destination.type, destination.startIndex) }, + key = viewModelKey, + creationCallback = { + it.create( + destination.type, + destination.startIndex, + destination.initialFilter, + ) + }, ), ) { val state by viewModel.state.collectAsState() - when (val s = state.loading) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier) - } - is DataLoadingState.Error -> { - ErrorMessage(s, modifier) - } + 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) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier) + } - is DataLoadingState.Success> -> { - val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - GridTitle(destination.type.title.getString()) + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier) + } + is DataLoadingState.Success> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( - initialPosition = destination.startIndex, + initialPosition = state.startIndex, pager = s.data, onClickItem = { index, item -> viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) @@ -222,6 +315,12 @@ fun DiscoverRequestGrid( width = Dp.Unspecified, ) }, + columns = 6, + positionCallback = { columns, index -> + showHeader = index < columns + positionCallback.invoke(columns, index) + }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 17c52c37..360bdfb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R 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.DiscoverRating 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.StringProvider 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.successValue import com.google.common.cache.CacheBuilder @@ -495,7 +495,9 @@ fun SeerrDiscoverPage( positionCallback.invoke(position) } viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(DiscoverPagerType.RequestType(row.type)), + Destination.DiscoverMoreResult( + type = row.type, + ), ) }, modifier = @@ -534,11 +536,15 @@ fun SeerrDiscoverPage( position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( Destination.DiscoverMoreResult( - type = - DiscoverPagerType.Genre( - genreId = genre.id.toInt(), - name = genre.name, - type = SeerrItemType.MOVIE, + type = DiscoverRequestType.DISCOVER_MOVIES, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.MOVIE, ), startIndex = 0, ), @@ -571,11 +577,15 @@ fun SeerrDiscoverPage( position = RowColumn(ROW_GENRES_TV, index) viewModel.navigationManager.navigateTo( Destination.DiscoverMoreResult( - type = - DiscoverPagerType.Genre( - genreId = genre.id.toInt(), - name = genre.name, - type = SeerrItemType.TV, + type = DiscoverRequestType.DISCOVER_TV, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.TV, ), startIndex = 0, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 390c4eb3..96863e05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.nav 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.CollectionFolderFilter 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.preferences.PreferenceScreenOption 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.SEERR_PAGE_SIZE import kotlinx.serialization.Contextual @@ -148,8 +149,10 @@ sealed class Destination( @Serializable data class DiscoverMoreResult( - val type: DiscoverPagerType, + val type: DiscoverRequestType, val startIndex: Int = SEERR_PAGE_SIZE, + val initialFilter: DiscoverFilter = DiscoverFilter(), + val titleOverride: StringProvider? = null, ) : Destination(false) @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index db723522..12f4f4d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -405,6 +405,8 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverRequestGrid( destination = destination, + showTitle = true, + positionCallback = { _, _ -> }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt index c4e7ae44..ceb4f160 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieResult 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.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi @@ -82,35 +83,27 @@ interface DiscoverRequestHandler { } class DiscoverTvRequestHandler( - private val genre: Int? = null, + val filter: DiscoverFilter = DiscoverFilter(), ) : DiscoverRequestHandler { override suspend fun execute( api: SeerrApi, pageNumber: Int, ): QueryResult = - api.api.searchApi - .discoverTvGet( - page = pageNumber, - genre = genre?.toString(), - ).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } + filter.discoverTv(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } } class DiscoverMovieRequestHandler( - private val genre: Int? = null, + val filter: DiscoverFilter = DiscoverFilter(), ) : DiscoverRequestHandler { override suspend fun execute( api: SeerrApi, pageNumber: Int, ): QueryResult = - api.api.searchApi - .discoverMoviesGet( - page = pageNumber, - genre = genre?.toString(), - ).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } + filter.discoverMovies(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } } val TrendingRequestHandler = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a97d522..3dbbe6de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -826,4 +826,6 @@ Quality profile Default Root folder + Popularity + Production status diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 254bdb09..7c128bbd 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1199,6 +1199,7 @@ components: $ref: '#/components/schemas/Season' status: type: string + enum: ["Returning Series", "Planned", "In Production", "Ended", "Cancelled", "Pilot"] tagline: type: string type: