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 val destination: Destination
get() { get() {
val jfType = val jfType = type.baseItemKind
when (type) {
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
SeerrItemType.TV -> BaseItemKind.SERIES
SeerrItemType.PERSON -> BaseItemKind.PERSON
SeerrItemType.UNKNOWN -> null
}
return if (jellyfinItemId != null && jfType != null) { return if (jellyfinItemId != null && jfType != null) {
Destination.MediaItem( Destination.MediaItem(
itemId = jellyfinItemId, itemId = jellyfinItemId,

View file

@ -161,10 +161,12 @@ class SeerrService
null null
} }
private suspend fun createImageUrl( suspend fun createImageUrl(
imageType: ImageType, imageType: ImageType,
path: String?, path: String?,
mediaInfo: MediaInfo?, mediaInfo: MediaInfo?,
backdropHeight: Int = 1080,
backdropWidth: Int = 1920,
): String? { ): String? {
if (mediaInfo != null) { if (mediaInfo != null) {
val itemId = val itemId =
@ -193,7 +195,7 @@ class SeerrService
val prefix = val prefix =
when (imageType) { when (imageType) {
ImageType.PRIMARY -> "/t/p/w500" 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") else -> throw IllegalArgumentException("Image type not supported: $imageType")
} }
return "${base}${prefix}$path" 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.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.DiscoverItem 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.NavigationManager
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler
import com.github.damontecres.wholphin.util.DiscoverPagerType
import com.github.damontecres.wholphin.util.DiscoverRequestPager import com.github.damontecres.wholphin.util.DiscoverRequestPager
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler
@ -52,13 +53,13 @@ class DiscoverRequestViewModel
private val seerrService: SeerrService, private val seerrService: SeerrService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val api: SeerrApi, private val api: SeerrApi,
@Assisted val type: DiscoverRequestType, @Assisted val type: DiscoverPagerType,
@Assisted startIndex: Int, @Assisted startIndex: Int,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
type: DiscoverRequestType, type: DiscoverPagerType,
startIndex: Int, startIndex: Int,
): DiscoverRequestViewModel ): DiscoverRequestViewModel
} }
@ -71,53 +72,83 @@ class DiscoverRequestViewModel
try { try {
val pager = val pager =
when (type) { when (type) {
DiscoverRequestType.DISCOVER_TV -> { is DiscoverPagerType.RequestType -> {
DiscoverRequestPager( when (type.type) {
api, DiscoverRequestType.DISCOVER_TV -> {
DiscoverTvRequestHandler, DiscoverRequestPager(
seerrService::createDiscoverItem, api,
viewModelScope, 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 -> { is DiscoverPagerType.Genre -> {
DiscoverRequestPager( when (type.type) {
api, SeerrItemType.MOVIE -> {
DiscoverMovieRequestHandler, DiscoverRequestPager(
seerrService::createDiscoverItem, api,
viewModelScope, DiscoverMovieRequestHandler(genre = type.genreId),
) seerrService::createDiscoverItem,
} viewModelScope,
)
}
DiscoverRequestType.TRENDING -> { SeerrItemType.TV -> {
DiscoverRequestPager( DiscoverRequestPager(
api, api,
TrendingRequestHandler, DiscoverTvRequestHandler(genre = type.genreId),
seerrService::createDiscoverItem, seerrService::createDiscoverItem,
viewModelScope, viewModelScope,
) )
} }
DiscoverRequestType.UPCOMING_TV -> { else -> {
DiscoverRequestPager( throw UnsupportedOperationException("Type not supported: ${type.type}")
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) }.init(startIndex)
_state.update { _state.update {
@ -168,7 +199,7 @@ fun DiscoverRequestGrid(
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
GridTitle(stringResource(destination.type.stringRes)) GridTitle(destination.type.title.getString())
CardGrid( CardGrid(
initialPosition = destination.startIndex, 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -23,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp 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.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService 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.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString 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.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.EmptyStringProvider 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.ResStringProvider
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverPagerType
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.successValue
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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( private fun fetchAndUpdateState(
@ -149,11 +232,13 @@ class SeerrDiscoverViewModel
} }
} }
fun updateBackdrop(item: DiscoverItem?) { fun updateBackdrop(item: DiscoverFocusedItem?) {
viewModelScope.launchIO { viewModelScope.launchIO {
if (item != null) { if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl) backdropService.submit("discover_${item.id}", item.backDropUrl)
fetchRating(item) if (item is DiscoverFocusedItem.Item) {
fetchRating(item.id, item.item.type)
}
} else { } else {
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
@ -167,25 +252,28 @@ class SeerrDiscoverViewModel
.build<Int, DiscoverRating>() .build<Int, DiscoverRating>()
// TODO this is not very efficient // TODO this is not very efficient
fun fetchRating(item: DiscoverItem) { fun fetchRating(
id: Int,
type: SeerrItemType,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
val cachedResult = ratingCache.getIfPresent(item.id) val cachedResult = ratingCache.getIfPresent(id)
if (cachedResult != null) { if (cachedResult != null) {
return@launchIO return@launchIO
} }
val result = val result =
try { try {
when (item.type) { when (type) {
SeerrItemType.MOVIE -> { SeerrItemType.MOVIE -> {
DiscoverRating( DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet( seerrService.api.moviesApi.movieMovieIdRatingsGet(
movieId = item.id, movieId = id,
), ),
) )
} }
SeerrItemType.TV -> { SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id))
} }
SeerrItemType.PERSON -> { SeerrItemType.PERSON -> {
@ -198,17 +286,17 @@ class SeerrDiscoverViewModel
} }
} catch (ex: ClientException) { } catch (ex: ClientException) {
if (ex.statusCode == 404) { if (ex.statusCode == 404) {
Timber.w("No rating found for %s", item.id) Timber.w("No rating found for %s", id)
DiscoverRating(null, null) DiscoverRating(null, null)
} else { } else {
Timber.e(ex, "Error getting rating for %s", item.id) Timber.e(ex, "Error getting rating for %s", id)
return@launchIO return@launchIO
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error getting rating for %s", item.id) Timber.e(ex, "Error getting rating for %s", id)
return@launchIO return@launchIO
} }
ratingCache.put(item.id, result) ratingCache.put(id, result)
rating.update { rating.update {
ratingCache.asMap().toMap() ratingCache.asMap().toMap()
} }
@ -216,6 +304,10 @@ class SeerrDiscoverViewModel
} }
} }
fun Int.toUUID() = UUID(0L, toLong())
fun UUID.toInt() = leastSignificantBits.toInt()
data class DiscoverRowData( data class DiscoverRowData(
val title: StringProvider, val title: StringProvider,
val items: DataLoadingState<List<DiscoverItem>>, val items: DataLoadingState<List<DiscoverItem>>,
@ -237,8 +329,20 @@ data class DiscoverState(
val trending: DiscoverRowData = DiscoverRowData.EMPTY, val trending: DiscoverRowData = DiscoverRowData.EMPTY,
val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY,
val upcomingTv: 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SeerrDiscoverPage( fun SeerrDiscoverPage(
@ -248,16 +352,62 @@ fun SeerrDiscoverPage(
viewModel: SeerrDiscoverViewModel = hiltViewModel(), viewModel: SeerrDiscoverViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() 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 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) var position by rememberPosition(0, -1)
val focusedItem = val focusedItem: DiscoverFocusedItem? =
remember(position) { remember(position) {
position.let { 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) { LaunchedEffect(focusedItem) {
@ -277,25 +427,27 @@ fun SeerrDiscoverPage(
) { ) {
val details = val details =
remember(focusedItem, ratingMap) { remember(focusedItem, ratingMap) {
buildList { (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
focusedItem buildList {
?.releaseDate focusedItem.item
?.year .releaseDate
?.toString() ?.year
?.let(::add) ?.toString()
}.let { ?.let(::add)
val rating = focusedItem?.id?.let { ratingMap[it] } }.let {
val str = val rating = focusedItem.id.let { ratingMap[it] }
listToDotString( val str =
it, listToDotString(
rating?.audienceRating, it,
rating?.criticRating?.toFloat(), rating?.audienceRating,
) rating?.criticRating?.toFloat(),
QuickDetailsData(str) )
QuickDetailsData(str)
}
} }
} }
HomePageHeader( HomePageHeader(
title = focusedItem?.title, title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle, subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview, overview = focusedItem?.overview,
overviewTwoLines = true, overviewTwoLines = true,
@ -309,6 +461,7 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f), .fillMaxHeight(.25f),
) )
val density = LocalDensity.current val density = LocalDensity.current
val spaceAbovePx = val spaceAbovePx =
with(density) { with(density) {
@ -316,6 +469,41 @@ fun SeerrDiscoverPage(
50.dp.toPx() 50.dp.toPx()
} }
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current 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( CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) { ) {
@ -327,36 +515,81 @@ fun SeerrDiscoverPage(
.focusRestorer() .focusRestorer()
.fillMaxSize(), .fillMaxSize(),
) { ) {
itemsIndexed(rows) { rowIndex, row -> item { discoverItemRow(ROW_TRENDING, state.trending) }
// Movies
item { discoverItemRow(ROW_MOVIES, state.movies) }
item {
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
DiscoverRow( DiscoverGenreRow(
row = row, title =
onClickItem = { index, item -> remember {
position = RowColumn(rowIndex, index) ResProviderStringProvider(
viewModel.navigationManager.navigateTo(item.destination) R.string.genres_in,
}, ResStringProvider(R.string.movies_title),
onLongClickItem = { index, item -> }, )
onCardFocus = { index -> },
position = RowColumn(rowIndex, index) items = state.movieGenres,
positionCallback.invoke(position) onClickItem = { index, genre ->
}, position = RowColumn(ROW_GENRES_MOVIE, index)
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( 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 = onCardFocus = { index ->
Modifier position = RowColumn(ROW_GENRES_MOVIE, index)
.fillMaxWidth(), 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverPagerType
import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.RequestHandler
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
@ -146,8 +146,9 @@ sealed class Destination(
val item: DiscoverItem, val item: DiscoverItem,
) : Destination(false) ) : Destination(false)
@Serializable
data class DiscoverMoreResult( data class DiscoverMoreResult(
val type: DiscoverRequestType, val type: DiscoverPagerType,
val startIndex: Int = SEERR_PAGE_SIZE, val startIndex: Int = SEERR_PAGE_SIZE,
) : Destination(false) ) : 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.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.DiscoverItem 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.SeerrApi
import com.github.damontecres.wholphin.services.SeerrSearchResult 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.coroutines.CoroutineScope
import kotlinx.serialization.Serializable
/** /**
* A [RequestPager] for Seerr server queries * A [RequestPager] for Seerr server queries
@ -45,6 +50,27 @@ enum class DiscoverRequestType(
UNKNOWN(R.string.unknown), 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 * Specifies how a [RequestPager] should prepare and execute API calls
*/ */
@ -55,27 +81,37 @@ interface DiscoverRequestHandler<T> {
): QueryResult<T> ): QueryResult<T>
} }
val DiscoverTvRequestHandler = class DiscoverTvRequestHandler(
object : DiscoverRequestHandler<TvResult> { private val genre: Int? = null,
override suspend fun execute( ) : DiscoverRequestHandler<TvResult> {
api: SeerrApi, override suspend fun execute(
pageNumber: Int, api: SeerrApi,
): QueryResult<TvResult> = pageNumber: Int,
api.api.searchApi.discoverTvGet(page = pageNumber).let { ): QueryResult<TvResult> =
api.api.searchApi
.discoverTvGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0) QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
} }
} }
val DiscoverMovieRequestHandler = class DiscoverMovieRequestHandler(
object : DiscoverRequestHandler<MovieResult> { private val genre: Int? = null,
override suspend fun execute( ) : DiscoverRequestHandler<MovieResult> {
api: SeerrApi, override suspend fun execute(
pageNumber: Int, api: SeerrApi,
): QueryResult<MovieResult> = pageNumber: Int,
api.api.searchApi.discoverMoviesGet(page = pageNumber).let { ): QueryResult<MovieResult> =
api.api.searchApi
.discoverMoviesGet(
page = pageNumber,
genre = genre?.toString(),
).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0) QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
} }
} }
val TrendingRequestHandler = val TrendingRequestHandler =
object : DiscoverRequestHandler<SeerrSearchResult> { object : DiscoverRequestHandler<SeerrSearchResult> {

View file

@ -821,6 +821,8 @@
<string name="tomatoes">Tomatoes</string> <string name="tomatoes">Tomatoes</string>
<string name="sort">Sort</string> <string name="sort">Sort</string>
<string name="split_into_separate_rows">Split into separate rows</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="quality_profile">Quality profile</string>
<string name="is_default">Default</string> <string name="is_default">Default</string>
<string name="root_folder">Root folder</string> <string name="root_folder">Root folder</string>