mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
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:
parent
c945e1a472
commit
f21b29fe1b
9 changed files with 564 additions and 130 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue