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
<img width="1280" height="720" alt="discover_genres Large"
src="https://github.com/user-attachments/assets/93e5ef25-e253-49bc-9009-89f814889c98"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-22 19:35:44 -04:00 • committed by GitHub
parent c945e1a472
commit f21b29fe1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 564 additions and 130 deletions

View file

@ -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,

View file

@ -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"

View file

@ -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")
}

View file

@ -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<List<Genre>>,
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<List<Genre>> -> {
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 = {},
)
}
}
}

View file

@ -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,

View file

@ -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<Int, DiscoverRating>()
// 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<List<DiscoverItem>>,
@ -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<List<Genre>> = DataLoadingState.Pending,
val tvGenres: DataLoadingState<List<Genre>> = 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<List<DiscoverItem>>)?.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<List<DiscoverItem>>)?.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) }
}
}
}

View file

@ -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)

View file

@ -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<T> {
): QueryResult<T>
}
val DiscoverTvRequestHandler =
object : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi.discoverTvGet(page = pageNumber).let {
class DiscoverTvRequestHandler(
private val genre: Int? = null,
) : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi
.discoverTvGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
}
val DiscoverMovieRequestHandler =
object : DiscoverRequestHandler<MovieResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<MovieResult> =
api.api.searchApi.discoverMoviesGet(page = pageNumber).let {
class DiscoverMovieRequestHandler(
private val genre: Int? = null,
) : DiscoverRequestHandler<MovieResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<MovieResult> =
api.api.searchApi
.discoverMoviesGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
}
val TrendingRequestHandler =
object : DiscoverRequestHandler<SeerrSearchResult> {

View file

@ -821,6 +821,8 @@
<string name="tomatoes">Tomatoes</string>
<string name="sort">Sort</string>
<string name="split_into_separate_rows">Split into separate rows</string>
<string name="genre_movies">%1$s Movies</string>
<string name="genre_tv_shows">%1$s TV Shows</string>
<string name="quality_profile">Quality profile</string>
<string name="is_default">Default</string>
<string name="root_folder">Root folder</string>