View more discover results (#1198)

## Description
Adds a card at the end of the rows on the discover page/tab to view more
results. When clicking this card, it goes to a grid view of more results
starting focused on the next one.

### Dev notes

This PR modifies `ApiRequestPager` to move most of its functionality
into an abstract class, `RequestPager`. And then add a new concrete
class, `DiscoverRequestPager`, for Seerr API paging. `ApiRequestPager`
remains for Jellyfin paging.

### Related issues
Related to #1035

### Testing
Emulator

## Screenshots

![discover_view_more
Large](https://github.com/user-attachments/assets/180aa7a5-45c3-4544-b3f8-e3d394859178)
![discover_grid
Large](https://github.com/user-attachments/assets/ae5e8fbf-0fa4-4a2e-96ac-629ae688c05d)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-06 13:18:55 -04:00 • committed by GitHub
parent 1366ee744d
commit 9a3af225a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 926 additions and 246 deletions

View file

@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -33,8 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -56,6 +60,7 @@ fun DiscoverItemCard(
modifier: Modifier = Modifier,
showOverlay: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -75,16 +80,15 @@ fun DiscoverItemCard(
} else {
focusedAfterDelay = false
}
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
modifier = modifier.size(width, Dp.Unspecified),
) {
Card(
modifier =
Modifier
.size(Dp.Unspecified, Cards.height2x3)
.size(width, Dp.Unspecified)
.aspectRatio(AspectRatios.TALL),
onClick = onClick,
onLongClick = onLongClick,
@ -284,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
}
}
@Composable
fun DiscoverViewMoreCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(Dp.Unspecified, Cards.height2x3)
.aspectRatio(AspectRatios.TALL),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
Icon(
imageVector = Icons.Default.ArrowForward,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = "View more",
modifier = Modifier.fillMaxSize(),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.view_more),
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}
@PreviewTvSpec
@Composable
private fun Preview() {
@ -292,6 +378,11 @@ private fun Preview() {
PendingIndicator()
AvailableIndicator()
PartiallyAvailableIndicator()
DiscoverViewMoreCard(
onClick = {},
onLongClick = {},
modifier = Modifier,
)
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@ -59,12 +60,8 @@ fun <T> ItemRow(
}
},
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
ItemRowTitle(title)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
@ -113,3 +110,15 @@ fun <T> ItemRow(
}
}
}
@Composable
@NonRestartableComposable
fun ItemRowTitle(
title: String,
modifier: Modifier = Modifier,
) = Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier.padding(start = 8.dp),
)

View file

@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -882,15 +883,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(),
) {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
GridTitle(title)
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
@ -1146,3 +1139,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf()
}
}
@Composable
@NonRestartableComposable
fun GridTitle(
title: String,
modifier: Modifier = Modifier,
) = Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.fillMaxWidth(),
)

View file

@ -73,6 +73,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@ -386,6 +387,7 @@ fun PersonPageContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = RowColumn(DISCOVER_ROW, index)

View file

@ -70,6 +70,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
@ -570,6 +571,7 @@ fun MovieDetailsContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW

View file

@ -87,6 +87,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -653,6 +654,7 @@ fun SeriesDetailsContent(
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN,
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW

View file

@ -0,0 +1,198 @@
package com.github.damontecres.wholphin.ui.discover
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridTitle
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.launchDefault
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.DiscoverRequestPager
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler
import com.github.damontecres.wholphin.util.TrendingRequestHandler
import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler
import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class)
class DiscoverRequestViewModel
@AssistedInject
constructor(
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
val navigationManager: NavigationManager,
private val api: SeerrApi,
@Assisted val type: DiscoverRequestType,
@Assisted startIndex: Int,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(
type: DiscoverRequestType,
startIndex: Int,
): DiscoverRequestViewModel
}
private val _state = MutableStateFlow(DiscoverRequestState())
val state: StateFlow<DiscoverRequestState> = _state
init {
viewModelScope.launchDefault {
try {
val pager =
when (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")
}
}.init(startIndex)
_state.update {
it.copy(
loading = DataLoadingState.Success(pager),
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error initializing %s", type)
_state.update {
it.copy(
loading = DataLoadingState.Error(ex),
)
}
}
}
}
}
data class DiscoverRequestState(
val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending,
)
@Composable
fun DiscoverRequestGrid(
destination: Destination.DiscoverMoreResult,
modifier: Modifier = Modifier,
viewModel: DiscoverRequestViewModel =
hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>(
creationCallback = { it.create(destination.type, destination.startIndex) },
),
) {
val state by viewModel.state.collectAsState()
when (val s = state.loading) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
LoadingPage(modifier)
}
is DataLoadingState.Error -> {
ErrorMessage(s, modifier)
}
is DataLoadingState.Success<List<DiscoverItem?>> -> {
val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
GridTitle(stringResource(destination.type.stringRes))
CardGrid(
initialPosition = destination.startIndex,
pager = s.data,
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
},
onLongClickItem = { index, item -> },
onClickPlay = { _, _ -> },
letterPosition = { 0 },
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
showLetterButtons = false,
cardContent = { item, onClick, onLongClick, mod ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
width = Dp.Unspecified,
)
},
)
}
}
}
}

View file

@ -0,0 +1,194 @@
package com.github.damontecres.wholphin.ui.discover
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
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.data.model.DiscoverItem
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
@Composable
fun DiscoverRow(
row: DiscoverRowData,
onClickItem: (Int, DiscoverItem) -> Unit,
onLongClickItem: (Int, DiscoverItem) -> Unit,
onCardFocus: (Int) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
enableViewMore: Boolean = false,
onClickViewMore: () -> Unit = {},
) {
when (val state = row.items) {
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception, modifier)
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = row.title,
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<DiscoverItem>> -> {
DiscoverItemRow(
title = row.title,
items = state.data,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onCardFocus = onCardFocus,
enableViewMore = enableViewMore,
onClickViewMore = onClickViewMore,
modifier = modifier.focusRequester(focusRequester),
)
}
}
}
@Composable
fun DiscoverItemRow(
title: String,
items: List<DiscoverItem?>,
onClickItem: (Int, DiscoverItem) -> Unit,
onLongClickItem: (Int, DiscoverItem) -> Unit,
onCardFocus: (Int) -> Unit,
enableViewMore: Boolean,
modifier: Modifier = Modifier,
horizontalPadding: Dp = 16.dp,
onClickViewMore: () -> Unit = {},
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var position by rememberInt()
val currentOnClickItem by rememberUpdatedState(onClickItem)
val currentOnLongClickItem by rememberUpdatedState(onLongClickItem)
val currentOnCardFocus by rememberUpdatedState(onCardFocus)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier.focusProperties {
onEnter = {
focusRequester.tryRequestFocus()
}
},
) {
ItemRowTitle(title)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()
.focusGroup()
.focusRestorer(firstFocus)
.focusRequester(focusRequester),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
remember(index, position) {
if (index == position) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
}.onFocusChanged {
if (it.isFocused) {
currentOnCardFocus.invoke(index)
}
}
}
val onClick =
remember(index, item) {
{
position = index
if (item != null) currentOnClickItem(index, item)
}
}
val onLongClick =
remember(index, item) {
{
position = index
if (item != null) currentOnLongClickItem(index, item)
}
}
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = cardModifier,
)
}
if (enableViewMore) {
item {
DiscoverViewMoreCard(
onClick =
remember {
{
position = items.size
onClickViewMore.invoke()
}
},
onLongClick = {},
modifier =
Modifier
.ifElse(items.size == position, Modifier.focusRequester(firstFocus))
.onFocusChanged {
if (it.isFocused) {
currentOnCardFocus.invoke(items.size)
}
},
)
}
}
}
}
}

View file

@ -23,18 +23,14 @@ 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.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
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 androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrItemType
@ -42,23 +38,22 @@ 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.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
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.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
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.api.client.ApiClient
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -68,7 +63,6 @@ class SeerrDiscoverViewModel
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
val navigationManager: NavigationManager,
private val api: ApiClient,
private val backdropService: BackdropService,
) : ViewModel() {
val state = MutableStateFlow<DiscoverState>(DiscoverState())
@ -79,24 +73,53 @@ class SeerrDiscoverViewModel
backdropService.clearBackdrop()
}
fetchAndUpdateState(seerrService::discoverMovies) {
this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
this.copy(
movies =
DiscoverRowData(
context.getString(R.string.movies),
it,
DiscoverRequestType.DISCOVER_MOVIES,
),
)
}
fetchAndUpdateState(seerrService::discoverTv) {
this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
this.copy(
tv =
DiscoverRowData(
context.getString(R.string.tv_shows),
it,
DiscoverRequestType.DISCOVER_TV,
),
)
}
fetchAndUpdateState(seerrService::trending) {
this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it))
this.copy(
trending =
DiscoverRowData(
context.getString(R.string.trending),
it,
DiscoverRequestType.TRENDING,
),
)
}
fetchAndUpdateState(seerrService::upcomingMovies) {
this.copy(
upcomingMovies =
DiscoverRowData(context.getString(R.string.upcoming_movies), it),
DiscoverRowData(
context.getString(R.string.upcoming_movies),
it,
DiscoverRequestType.UPCOMING_MOVIES,
),
)
}
fetchAndUpdateState(seerrService::upcomingTv) {
this.copy(
upcomingTv =
DiscoverRowData(context.getString(R.string.upcoming_tv), it),
DiscoverRowData(
context.getString(R.string.upcoming_tv),
it,
DiscoverRequestType.UPCOMING_TV,
),
)
}
}
@ -127,6 +150,8 @@ class SeerrDiscoverViewModel
if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl)
fetchRating(item)
} else {
backdropService.clearBackdrop()
}
}
}
@ -145,26 +170,39 @@ class SeerrDiscoverViewModel
return@launchIO
}
val result =
when (item.type) {
SeerrItemType.MOVIE -> {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(
movieId = item.id,
),
)
}
try {
when (item.type) {
SeerrItemType.MOVIE -> {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(
movieId = item.id,
),
)
}
SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
}
SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
}
SeerrItemType.PERSON -> {
DiscoverRating(null, null)
}
SeerrItemType.UNKNOWN -> {
SeerrItemType.PERSON -> {
DiscoverRating(null, null)
}
SeerrItemType.UNKNOWN -> {
DiscoverRating(null, null)
}
}
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
Timber.w("No rating found for %s", item.id)
DiscoverRating(null, null)
} else {
Timber.e(ex, "Error getting rating for %s", item.id)
return@launchIO
}
} catch (ex: Exception) {
Timber.e(ex, "Error getting rating for %s", item.id)
return@launchIO
}
ratingCache.put(item.id, result)
rating.update {
@ -177,9 +215,10 @@ class SeerrDiscoverViewModel
data class DiscoverRowData(
val title: String,
val items: DataLoadingState<List<DiscoverItem>>,
val type: DiscoverRequestType,
) {
companion object {
val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN)
}
}
@ -286,6 +325,15 @@ fun SeerrDiscoverPage(
onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex],
enableViewMore = row.type != DiscoverRequestType.UNKNOWN,
onClickViewMore = {
(row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let {
position = RowColumn(rowIndex, it)
}
viewModel.navigationManager.navigateTo(
Destination.DiscoverMoreResult(row.type),
)
},
modifier =
Modifier
.fillMaxWidth(),
@ -296,63 +344,3 @@ fun SeerrDiscoverPage(
}
}
}
@Composable
fun DiscoverRow(
row: DiscoverRowData,
onClickItem: (Int, DiscoverItem) -> Unit,
onLongClickItem: (Int, DiscoverItem) -> Unit,
onCardFocus: (Int) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
when (val state = row.items) {
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception, modifier)
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = row.title,
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<DiscoverItem>> -> {
ItemRow(
title = row.title,
items = state.data,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier =
mod.onFocusChanged {
if (it.isFocused) {
onCardFocus.invoke(index)
}
},
)
},
modifier = modifier.focusRequester(focusRequester),
)
}
}
}

View file

@ -12,6 +12,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
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.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
@ -129,6 +131,11 @@ sealed class Destination(
val item: DiscoverItem,
) : Destination(false)
data class DiscoverMoreResult(
val type: DiscoverRequestType,
val startIndex: Int = SEERR_PAGE_SIZE,
) : Destination(false)
@Serializable
data object NowPlaying : Destination(true)

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
@ -353,6 +354,14 @@ fun DestinationContent(
}
}
}
is Destination.DiscoverMoreResult -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
DiscoverRequestGrid(
destination = destination,
modifier = modifier,
)
}
}
}