From f21b29fe1b9c44d8c6da316fe5847aefb2598efe Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 19:35:44 -0400 Subject: [PATCH] Discover movies & tv shows by genre (#1610) ## Description Adds genre rows for movies & TV shows to the discover page. These work the same as on the Seerr web UI, clicking one will show media in the genre. ### Related issues Related to https://github.com/damontecres/Wholphin/issues/702 ### Testing Just on the emulator so far ## Screenshots discover_genres Large ## AI or LLM usage None --- .../wholphin/data/model/DiscoverItem.kt | 8 +- .../wholphin/services/SeerrService.kt | 6 +- .../ui/discover/DiscoverFocusedItem.kt | 53 +++ .../wholphin/ui/discover/DiscoverGenreRow.kt | 82 +++++ .../ui/discover/DiscoverRequestGrid.kt | 125 ++++--- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 345 +++++++++++++++--- .../wholphin/ui/nav/Destination.kt | 5 +- .../wholphin/util/DiscoverRequestPager.kt | 68 +++- app/src/main/res/values/strings.xml | 2 + 9 files changed, 564 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index db03ff48..471cad6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -92,13 +92,7 @@ data class DiscoverItem( val destination: Destination get() { - val jfType = - when (type) { - SeerrItemType.MOVIE -> BaseItemKind.MOVIE - SeerrItemType.TV -> BaseItemKind.SERIES - SeerrItemType.PERSON -> BaseItemKind.PERSON - SeerrItemType.UNKNOWN -> null - } + val jfType = type.baseItemKind return if (jellyfinItemId != null && jfType != null) { Destination.MediaItem( itemId = jellyfinItemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 9643da39..1a484f90 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -161,10 +161,12 @@ class SeerrService null } - private suspend fun createImageUrl( + suspend fun createImageUrl( imageType: ImageType, path: String?, mediaInfo: MediaInfo?, + backdropHeight: Int = 1080, + backdropWidth: Int = 1920, ): String? { if (mediaInfo != null) { val itemId = @@ -193,7 +195,7 @@ class SeerrService val prefix = when (imageType) { ImageType.PRIMARY -> "/t/p/w500" - ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces" else -> throw IllegalArgumentException("Image type not supported: $imageType") } return "${base}${prefix}$path" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt new file mode 100644 index 00000000..c9464528 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.discover + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +sealed interface DiscoverFocusedItem { + val id: Int + val title: StringProvider? + val subtitle: String? + val overview: String? + val backDropUrl: String? + + data class Item( + val item: DiscoverItem, + ) : DiscoverFocusedItem { + override val id: Int + get() = item.id + override val title: StringProvider? = item.title?.let { StringStringProvider(it) } + override val subtitle: String? + get() = item.subtitle + override val overview: String? + get() = item.overview + override val backDropUrl: String? + get() = item.backDropUrl + } + + data class Genre( + override val id: Int, + val name: String, + val type: SeerrItemType, + override val backDropUrl: String?, + ) : DiscoverFocusedItem { + override val title: StringProvider = discoverGenreTitle(name, type) + override val subtitle: String? + get() = null + override val overview: String? + get() = null + } +} + +fun discoverGenreTitle( + name: String, + type: SeerrItemType, +): ResArgStringProvider = + when (type) { + SeerrItemType.MOVIE -> ResArgStringProvider(R.string.genre_movies, name) + SeerrItemType.TV -> ResArgStringProvider(R.string.genre_tv_shows, name) + else -> throw UnsupportedOperationException("$type not supported") + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt new file mode 100644 index 00000000..c61c0799 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.GenreCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverGenreRow( + title: StringProvider, + items: DataLoadingState>, + onClickItem: (Int, Genre) -> Unit, + onCardFocus: (Int) -> Unit, + modifier: Modifier = Modifier, + onLongClickItem: (Int, Genre) -> Unit = { _, _ -> }, +) { + when (items) { + is DataLoadingState.Error -> { + ErrorMessage(items.message, items.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = title.getString(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + ItemRow( + title = title.getString(), + items = items.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index, item, mod, onClick, onLongClick -> + GenreCard( + genre = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = + mod + .height(Cards.heightEpisode) + .onFocusChanged { + if (it.isFocused) onCardFocus.invoke(index) + }, + ) + }, + modifier = modifier, + horizontalPadding = 16.dp, + showViewMore = false, + viewMoreCardContent = {}, + ) + } + } +} 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 aa463ad6..08a9efe1 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 @@ -9,12 +9,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService @@ -28,6 +28,7 @@ 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 @@ -52,13 +53,13 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, - @Assisted val type: DiscoverRequestType, + @Assisted val type: DiscoverPagerType, @Assisted startIndex: Int, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - type: DiscoverRequestType, + type: DiscoverPagerType, startIndex: Int, ): DiscoverRequestViewModel } @@ -71,53 +72,83 @@ class DiscoverRequestViewModel try { val pager = when (type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) + 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") + } + } } - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } + is DiscoverPagerType.Genre -> { + when (type.type) { + SeerrItemType.MOVIE -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(genre = type.genreId), + seerrService::createDiscoverItem, + viewModelScope, + ) + } - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } + SeerrItemType.TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(genre = type.genreId), + 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") + else -> { + throw UnsupportedOperationException("Type not supported: ${type.type}") + } + } } }.init(startIndex) _state.update { @@ -168,7 +199,7 @@ fun DiscoverRequestGrid( Column( modifier = modifier, ) { - GridTitle(stringResource(destination.type.stringRes)) + GridTitle(destination.type.title.getString()) CardGrid( initialPosition = destination.startIndex, 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 ccdda6d8..17c52c37 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 @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -23,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString @@ -47,16 +48,21 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider 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 import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -126,6 +132,83 @@ class SeerrDiscoverViewModel ), ) } + viewModelScope.launchIO { + val movieGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderMovieGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s movie genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting movie genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + movieGenres = movieGenres, + ) + } + } + + viewModelScope.launchIO { + val tvGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderTvGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s tv genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + tvGenres = tvGenres, + ) + } + } } private fun fetchAndUpdateState( @@ -149,11 +232,13 @@ class SeerrDiscoverViewModel } } - fun updateBackdrop(item: DiscoverItem?) { + fun updateBackdrop(item: DiscoverFocusedItem?) { viewModelScope.launchIO { if (item != null) { backdropService.submit("discover_${item.id}", item.backDropUrl) - fetchRating(item) + if (item is DiscoverFocusedItem.Item) { + fetchRating(item.id, item.item.type) + } } else { backdropService.clearBackdrop() } @@ -167,25 +252,28 @@ class SeerrDiscoverViewModel .build() // TODO this is not very efficient - fun fetchRating(item: DiscoverItem) { + fun fetchRating( + id: Int, + type: SeerrItemType, + ) { viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(item.id) + val cachedResult = ratingCache.getIfPresent(id) if (cachedResult != null) { return@launchIO } val result = try { - when (item.type) { + when (type) { SeerrItemType.MOVIE -> { DiscoverRating( seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = item.id, + movieId = id, ), ) } SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id)) } SeerrItemType.PERSON -> { @@ -198,17 +286,17 @@ class SeerrDiscoverViewModel } } catch (ex: ClientException) { if (ex.statusCode == 404) { - Timber.w("No rating found for %s", item.id) + Timber.w("No rating found for %s", id) DiscoverRating(null, null) } else { - Timber.e(ex, "Error getting rating for %s", item.id) + Timber.e(ex, "Error getting rating for %s", id) return@launchIO } } catch (ex: Exception) { - Timber.e(ex, "Error getting rating for %s", item.id) + Timber.e(ex, "Error getting rating for %s", id) return@launchIO } - ratingCache.put(item.id, result) + ratingCache.put(id, result) rating.update { ratingCache.asMap().toMap() } @@ -216,6 +304,10 @@ class SeerrDiscoverViewModel } } +fun Int.toUUID() = UUID(0L, toLong()) + +fun UUID.toInt() = leastSignificantBits.toInt() + data class DiscoverRowData( val title: StringProvider, val items: DataLoadingState>, @@ -237,8 +329,20 @@ data class DiscoverState( val trending: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, + val movieGenres: DataLoadingState> = DataLoadingState.Pending, + val tvGenres: DataLoadingState> = DataLoadingState.Pending, ) +private const val ROW_TRENDING = 0 +private const val ROW_MOVIES = ROW_TRENDING + 1 +private const val ROW_GENRES_MOVIE = ROW_MOVIES + 1 +private const val ROW_UPCOMING_MOVIES = ROW_GENRES_MOVIE + 1 +private const val ROW_TV = ROW_UPCOMING_MOVIES + 1 +private const val ROW_GENRES_TV = ROW_TV + 1 +private const val ROW_UPCOMING_TV = ROW_GENRES_TV + 1 + +private const val LAST_ROW = ROW_UPCOMING_TV + @OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( @@ -248,16 +352,62 @@ fun SeerrDiscoverPage( viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val rows = - listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember { List(LAST_ROW + 1) { FocusRequester() } } var position by rememberPosition(0, -1) - val focusedItem = + val focusedItem: DiscoverFocusedItem? = remember(position) { position.let { - (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + val discoverRow = + when (position.row) { + ROW_TRENDING -> state.trending + ROW_MOVIES -> state.movies + ROW_UPCOMING_MOVIES -> state.upcomingMovies + ROW_TV -> state.tv + ROW_UPCOMING_TV -> state.upcomingTv + else -> null + } + if (discoverRow != null) { + (discoverRow.items as? DataLoadingState.Success) + ?.data + ?.getOrNull(it.column) + ?.let { + DiscoverFocusedItem.Item(it) + } + } else { + when (position.row) { + ROW_GENRES_MOVIE -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.MOVIE, + backDropUrl = it.imageUrl, + ) + } + } + + ROW_GENRES_TV -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.TV, + backDropUrl = it.imageUrl, + ) + } + } + + else -> { + null + } + } + } } } LaunchedEffect(focusedItem) { @@ -277,25 +427,27 @@ fun SeerrDiscoverPage( ) { val details = remember(focusedItem, ratingMap) { - buildList { - focusedItem - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - }.let { - val rating = focusedItem?.id?.let { ratingMap[it] } - val str = - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) - QuickDetailsData(str) + (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> + buildList { + focusedItem.item + .releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem.id.let { ratingMap[it] } + val str = + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + QuickDetailsData(str) + } } } HomePageHeader( - title = focusedItem?.title, + title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = true, @@ -309,6 +461,7 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) + val density = LocalDensity.current val spaceAbovePx = with(density) { @@ -316,6 +469,41 @@ fun SeerrDiscoverPage( 50.dp.toPx() } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + + @Composable + fun discoverItemRow( + rowIndex: Int, + row: DiscoverRowData, + ) { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> + position = RowColumn(rowIndex, index) + positionCallback.invoke(position) + }, + focusRequester = focusRequesters[rowIndex], + enableViewMore = row.type != DiscoverRequestType.UNKNOWN, + onClickViewMore = { + (row.items as? DataLoadingState.Success>)?.data?.size?.let { + position = RowColumn(rowIndex, it) + positionCallback.invoke(position) + } + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult(DiscoverPagerType.RequestType(row.type)), + ) + }, + modifier = + Modifier + .fillMaxWidth(), + ) + } + } CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { @@ -327,36 +515,81 @@ fun SeerrDiscoverPage( .focusRestorer() .fillMaxSize(), ) { - itemsIndexed(rows) { rowIndex, row -> + item { discoverItemRow(ROW_TRENDING, state.trending) } + + // Movies + item { discoverItemRow(ROW_MOVIES, state.movies) } + item { CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> - position = RowColumn(rowIndex, index) - positionCallback.invoke(position) - }, - focusRequester = focusRequesters[rowIndex], - enableViewMore = row.type != DiscoverRequestType.UNKNOWN, - onClickViewMore = { - (row.items as? DataLoadingState.Success>)?.data?.size?.let { - position = RowColumn(rowIndex, it) - positionCallback.invoke(position) - } + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.movies_title), + ) + }, + items = state.movieGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(row.type), + Destination.DiscoverMoreResult( + type = + DiscoverPagerType.Genre( + genreId = genre.id.toInt(), + name = genre.name, + type = SeerrItemType.MOVIE, + ), + startIndex = 0, + ), ) }, - modifier = - Modifier - .fillMaxWidth(), + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_MOVIE, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_MOVIE]), ) } } + item { discoverItemRow(ROW_UPCOMING_MOVIES, state.upcomingMovies) } + + // TV + item { discoverItemRow(ROW_TV, state.tv) } + item { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.tv_shows_title), + ) + }, + items = state.tvGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_TV, index) + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = + DiscoverPagerType.Genre( + genreId = genre.id.toInt(), + name = genre.name, + type = SeerrItemType.TV, + ), + startIndex = 0, + ), + ) + }, + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_TV, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_TV]), + ) + } + } + item { discoverItemRow(ROW_UPCOMING_TV, state.upcomingTv) } } } } 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 206ce221..390c4eb3 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 @@ -14,7 +14,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.DiscoverRequestType +import com.github.damontecres.wholphin.util.DiscoverPagerType import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import kotlinx.serialization.Contextual @@ -146,8 +146,9 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + @Serializable data class DiscoverMoreResult( - val type: DiscoverRequestType, + val type: DiscoverPagerType, val startIndex: Int = SEERR_PAGE_SIZE, ) : Destination(false) 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 cee890ce..c4e7ae44 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 @@ -5,9 +5,14 @@ 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.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.discover.discoverGenreTitle +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable /** * A [RequestPager] for Seerr server queries @@ -45,6 +50,27 @@ enum class DiscoverRequestType( UNKNOWN(R.string.unknown), } +@Serializable +sealed interface DiscoverPagerType { + val title: StringProvider + + @Serializable + data class RequestType( + val type: DiscoverRequestType, + ) : DiscoverPagerType { + override val title: StringProvider = ResStringProvider(type.stringRes) + } + + @Serializable + data class Genre( + val genreId: Int, + val name: String, + val type: SeerrItemType, + ) : DiscoverPagerType { + override val title: StringProvider = discoverGenreTitle(name, type) + } +} + /** * Specifies how a [RequestPager] should prepare and execute API calls */ @@ -55,27 +81,37 @@ interface DiscoverRequestHandler { ): QueryResult } -val DiscoverTvRequestHandler = - object : DiscoverRequestHandler { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult = - api.api.searchApi.discoverTvGet(page = pageNumber).let { +class DiscoverTvRequestHandler( + private val genre: Int? = null, +) : 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) } - } +} -val DiscoverMovieRequestHandler = - object : DiscoverRequestHandler { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult = - api.api.searchApi.discoverMoviesGet(page = pageNumber).let { +class DiscoverMovieRequestHandler( + private val genre: Int? = null, +) : 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) } - } +} val TrendingRequestHandler = object : DiscoverRequestHandler { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index facdec73..0a97d522 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,6 +821,8 @@ Tomatoes Sort Split into separate rows + %1$s Movies + %1$s TV Shows Quality profile Default Root folder