From 9a3af225a4bb3c4fff0f32d58a9d36703152db54 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:18:55 -0400 Subject: [PATCH] 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 --- .../wholphin/services/SeerrService.kt | 12 ++ .../wholphin/ui/cards/DiscoverItemCard.kt | 99 ++++++++- .../damontecres/wholphin/ui/cards/ItemRow.kt | 21 +- .../ui/components/CollectionFolderGrid.kt | 26 ++- .../wholphin/ui/detail/PersonPage.kt | 2 + .../wholphin/ui/detail/movie/MovieDetails.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 2 + .../ui/discover/DiscoverRequestGrid.kt | 198 ++++++++++++++++++ .../wholphin/ui/discover/DiscoverRow.kt | 194 +++++++++++++++++ .../wholphin/ui/discover/SeerrDiscoverPage.kt | 172 +++++++-------- .../wholphin/ui/nav/Destination.kt | 7 + .../wholphin/ui/nav/DestinationContent.kt | 9 + .../wholphin/util/ApiRequestPager.kt | 166 +++------------ .../wholphin/util/DiscoverRequestPager.kt | 111 ++++++++++ .../damontecres/wholphin/util/RequestPager.kt | 148 +++++++++++++ app/src/main/res/values/strings.xml | 3 + 16 files changed, 926 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 0c1801ac..d9a340c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -191,6 +191,18 @@ class SeerrService return "${base}${prefix}$path" } + suspend fun createDiscoverItem(item: Any): DiscoverItem = + when (item) { + is MovieResult -> createDiscoverItem(item) + is MovieDetails -> createDiscoverItem(item) + is TvResult -> createDiscoverItem(item) + is TvDetails -> createDiscoverItem(item) + is SeerrSearchResult -> createDiscoverItem(item) + is CreditCast -> createDiscoverItem(item) + is CreditCrew -> createDiscoverItem(item) + else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}") + } + suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = DiscoverItem( id = movie.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 7f9129d0..93e9adcc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -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, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 5b929a9b..c9f6a142 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -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 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 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), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 4c8090e3..ae945726 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -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 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(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 5a01d8a3..a6a77e23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 7538fdd2..31e6233c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 3a95440f..0e1e6a8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt new file mode 100644 index 00000000..b3460265 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -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 = _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> = DataLoadingState.Pending, +) + +@Composable +fun DiscoverRequestGrid( + destination: Destination.DiscoverMoreResult, + modifier: Modifier = Modifier, + viewModel: DiscoverRequestViewModel = + hiltViewModel( + 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> -> { + 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, + ) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt new file mode 100644 index 00000000..806a816a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -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> -> { + 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, + 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) + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 4f09ee9d..6c5b299a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -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()) @@ -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>, + 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>)?.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> -> { - 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), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 936ce69f..df6053ad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 5591f56d..ca80bb66 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 08f9318e..f57dfa46 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -1,18 +1,10 @@ package com.github.damontecres.wholphin.util -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE -import com.google.common.cache.CacheBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.genresApi @@ -35,111 +27,36 @@ import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID -import java.util.function.Predicate /** - * Handles paging for an API request. You must call [init] prior to use. - * - * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. - * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. - * - * Finally, items are cached allow for backward and forward scrolling. + * A [RequestPager] for Jellyfin server queries */ class ApiRequestPager( val api: ApiClient, val request: T, val requestHandler: RequestHandler, - private val scope: CoroutineScope, - private val pageSize: Int = DEFAULT_PAGE_SIZE, + scope: CoroutineScope, + pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, private val useSeriesForPrimary: Boolean = false, -) : AbstractList(), - BlockingList { - private var items by mutableStateOf(ItemList(0, pageSize, mapOf())) - private var totalCount by mutableIntStateOf(-1) - private val mutex = Mutex() - private val cachedPages = - CacheBuilder - .newBuilder() - .maximumSize(cacheSize) - .build>() +) : RequestPager(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): ApiRequestPager = super.init(initialPosition) as ApiRequestPager - suspend fun init(initialPosition: Int = 0): ApiRequestPager { - if (totalCount < 0) { - fetchPageBlocking(initialPosition, true) - } - return this - } - - override operator fun get(index: Int): BaseItem? { - if (index in 0..): Int { - init() - for (i in 0 until totalCount) { - val currentItem = getBlocking(i) - if (currentItem != null && predicate.test(currentItem)) { - return i - } - } - return -1 - } - - override val size: Int - get() = totalCount - - private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { - fetchPageBlocking(position, false) - } - - private suspend fun fetchPageBlocking( - position: Int, - setTotalCount: Boolean, - ) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - setTotalCount, - ) - val result = requestHandler.execute(api, newRequest).content - if (setTotalCount) { - totalCount = result.totalRecordCount.coerceAtLeast(0) - } - val data = mutableListOf() - result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } - cachedPages.put(pageNumber, data) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) - } - } + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult { + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + includeTotalCount, + ) + val result = requestHandler.execute(api, newRequest).content + val data = mutableListOf() + result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) } + return QueryResult(data, result.totalRecordCount) } suspend fun refreshItem( @@ -161,7 +78,7 @@ class ApiRequestPager( if (page != null && index in page.indices) { page[index] = item cachedPages.put(pageNumber, page) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) + items = ItemList(size, pageSize, cachedPages.asMap()) } } } @@ -181,39 +98,10 @@ class ApiRequestPager( } fetchPageBlocking(position, true) } - - companion object { - private const val DEBUG = false - } - - class ItemList( - val size: Int, - val pageSize: Int, - val pages: Map>, - ) { - operator fun get(position: Int): T? { - val page = position / pageSize - val data = pages[page] - if (data != null) { - val index = position % pageSize - if (index in data.indices) { - return data[index] - } else { - // This can happen when items are removed while scrolling - Timber.w( - "Index $index not in data: position=$position, data.size=${data.size}", - ) - return null - } - } else { - return null - } - } - } } /** - * Specifies how the [ApiRequestPager] should prepare and execute API calls + * Specifies how a [RequestPager] should prepare and execute API calls */ interface RequestHandler { /** @@ -235,6 +123,7 @@ interface RequestHandler { ): Response } +@Serializable val GetItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -255,6 +144,7 @@ val GetItemsRequestHandler = ): Response = api.itemsApi.getItems(request) } +@Serializable val GetEpisodesRequestHandler = object : RequestHandler { override fun prepare( @@ -274,6 +164,7 @@ val GetEpisodesRequestHandler = ): Response = api.tvShowsApi.getEpisodes(request) } +@Serializable val GetResumeItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -294,6 +185,7 @@ val GetResumeItemsRequestHandler = ): Response = api.itemsApi.getResumeItems(request) } +@Serializable val GetNextUpRequestHandler = object : RequestHandler { override fun prepare( @@ -314,6 +206,7 @@ val GetNextUpRequestHandler = ): Response = api.tvShowsApi.getNextUp(request) } +@Serializable val GetSuggestionsRequestHandler = object : RequestHandler { override fun prepare( @@ -334,6 +227,7 @@ val GetSuggestionsRequestHandler = ): Response = api.suggestionsApi.getSuggestions(request) } +@Serializable val GetPlaylistItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -353,6 +247,7 @@ val GetPlaylistItemsRequestHandler = ): Response = api.playlistsApi.getPlaylistItems(request) } +@Serializable val GetGenresRequestHandler = object : RequestHandler { override fun prepare( @@ -392,6 +287,7 @@ val GetProgramsDtoHandler = ): Response = api.liveTvApi.getPrograms(request) } +@Serializable val GetPersonsHandler = object : RequestHandler { override fun prepare( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt new file mode 100644 index 00000000..cee890ce --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.util + +import androidx.annotation.StringRes +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.services.SeerrApi +import com.github.damontecres.wholphin.services.SeerrSearchResult +import kotlinx.coroutines.CoroutineScope + +/** + * A [RequestPager] for Seerr server queries + */ +class DiscoverRequestPager( + private val api: SeerrApi, + val requestHandler: DiscoverRequestHandler, + val transform: suspend (T) -> DiscoverItem, + scope: CoroutineScope, + pageSize: Int = SEERR_PAGE_SIZE, + cacheSize: Long = 16, +) : RequestPager(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): DiscoverRequestPager = super.init(initialPosition) as DiscoverRequestPager + + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult { + val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed + val transformed = result.items.map { transform.invoke(it) } + return QueryResult(transformed, result.totalCount) + } +} + +const val SEERR_PAGE_SIZE = 20 + +enum class DiscoverRequestType( + @param:StringRes val stringRes: Int, +) { + DISCOVER_TV(R.string.discover_tv), + DISCOVER_MOVIES(R.string.discover_movies), + TRENDING(R.string.trending), + UPCOMING_TV(R.string.upcoming_tv), + UPCOMING_MOVIES(R.string.upcoming_movies), + UNKNOWN(R.string.unknown), +} + +/** + * Specifies how a [RequestPager] should prepare and execute API calls + */ +interface DiscoverRequestHandler { + suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult +} + +val DiscoverTvRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTvGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val DiscoverMovieRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverMoviesGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val TrendingRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTrendingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingTvRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingMovieRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt new file mode 100644 index 00000000..c9f91534 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.util + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE +import com.google.common.cache.CacheBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.function.Predicate + +/** + * Handles paging for an API request. You must call [init] prior to use. + * + * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. + * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. + * + * Finally, items are cached allow for backward and forward scrolling. + */ +abstract class RequestPager( + protected val scope: CoroutineScope, + protected val pageSize: Int = DEFAULT_PAGE_SIZE, + cacheSize: Long = 8, +) : AbstractList(), + BlockingList { + protected var totalCount by mutableIntStateOf(-1) + protected var items by mutableStateOf(ItemList(0, pageSize, mapOf())) + protected val mutex = Mutex() + protected val cachedPages = + CacheBuilder + .newBuilder() + .maximumSize(cacheSize) + .build>() + + open suspend fun init(initialPosition: Int = 0): RequestPager { + if (totalCount < 0) { + fetchPageBlocking(initialPosition, true) + } + return this + } + + override operator fun get(index: Int): T? { + if (index in 0..): Int { + init(0) + for (i in 0 until totalCount) { + val currentItem = getBlocking(i) + if (currentItem != null && predicate.test(currentItem)) { + return i + } + } + return -1 + } + + override val size: Int + get() = totalCount + + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + Dispatchers.IO) { + fetchPageBlocking(position, false) + } + + protected suspend fun fetchPageBlocking( + position: Int, + setTotalCount: Boolean, + ) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val result = fetchPage(pageNumber, setTotalCount) + if (setTotalCount && result.totalCount != null) { + totalCount = result.totalCount.coerceAtLeast(0) + } + cachedPages.put(pageNumber, result.items.toMutableList()) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + + protected abstract suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult + + companion object { + internal const val DEBUG = false + } + + class ItemList( + val size: Int, + val pageSize: Int, + val pages: Map>, + ) { + operator fun get(position: Int): T? { + val page = position / pageSize + val data = pages[page] + if (data != null) { + val index = position % pageSize + if (index in data.indices) { + return data[index] + } else { + // This can happen when items are removed while scrolling + Timber.w( + "Index $index not in data: position=$position, data.size=${data.size}", + ) + return null + } + } else { + return null + } + } + } +} + +data class QueryResult( + val items: List, + val totalCount: Int?, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce4bf2b6..c41b5511 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,5 +749,8 @@ Separate types Prefer showing logos for titles Wholphin updated to %s + View more + Discover TV Shows + Discover Movies