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: