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

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

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,
)
}
}
}

View file

@ -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<T>(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler<T>,
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<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, MutableList<BaseItem>>()
) : RequestPager<BaseItem>(scope, pageSize, cacheSize) {
override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T>
suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> {
if (totalCount < 0) {
fetchPageBlocking(initialPosition, true)
}
return this
}
override operator fun get(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPageBlocking(index, false)
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): 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<BaseItem>()
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<BaseItem> {
val newRequest =
requestHandler.prepare(
request,
pageNumber * pageSize,
pageSize,
includeTotalCount,
)
val result = requestHandler.execute(api, newRequest).content
val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
return QueryResult(data, result.totalRecordCount)
}
suspend fun refreshItem(
@ -161,7 +78,7 @@ class ApiRequestPager<T>(
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<T>(
}
fetchPageBlocking(position, true)
}
companion object {
private const val DEBUG = false
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
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<T> {
/**
@ -235,6 +123,7 @@ interface RequestHandler<T> {
): Response<BaseItemDtoQueryResult>
}
@Serializable
val GetItemsRequestHandler =
object : RequestHandler<GetItemsRequest> {
override fun prepare(
@ -255,6 +144,7 @@ val GetItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
}
@Serializable
val GetEpisodesRequestHandler =
object : RequestHandler<GetEpisodesRequest> {
override fun prepare(
@ -274,6 +164,7 @@ val GetEpisodesRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
}
@Serializable
val GetResumeItemsRequestHandler =
object : RequestHandler<GetResumeItemsRequest> {
override fun prepare(
@ -294,6 +185,7 @@ val GetResumeItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
}
@Serializable
val GetNextUpRequestHandler =
object : RequestHandler<GetNextUpRequest> {
override fun prepare(
@ -314,6 +206,7 @@ val GetNextUpRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
}
@Serializable
val GetSuggestionsRequestHandler =
object : RequestHandler<GetSuggestionsRequest> {
override fun prepare(
@ -334,6 +227,7 @@ val GetSuggestionsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
@Serializable
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
@ -353,6 +247,7 @@ val GetPlaylistItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}
@Serializable
val GetGenresRequestHandler =
object : RequestHandler<GetGenresRequest> {
override fun prepare(
@ -392,6 +287,7 @@ val GetProgramsDtoHandler =
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}
@Serializable
val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> {
override fun prepare(

View file

@ -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<T>(
private val api: SeerrApi,
val requestHandler: DiscoverRequestHandler<T>,
val transform: suspend (T) -> DiscoverItem,
scope: CoroutineScope,
pageSize: Int = SEERR_PAGE_SIZE,
cacheSize: Long = 16,
) : RequestPager<DiscoverItem>(scope, pageSize, cacheSize) {
override suspend fun init(initialPosition: Int): DiscoverRequestPager<T> = super.init(initialPosition) as DiscoverRequestPager<T>
override suspend fun fetchPage(
pageNumber: Int,
includeTotalCount: Boolean,
): QueryResult<DiscoverItem> {
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<T> {
suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<T>
}
val DiscoverTvRequestHandler =
object : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi.discoverTvGet(page = pageNumber).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 {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val TrendingRequestHandler =
object : DiscoverRequestHandler<SeerrSearchResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<SeerrSearchResult> =
api.api.searchApi.discoverTrendingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val UpcomingTvRequestHandler =
object : DiscoverRequestHandler<TvResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<TvResult> =
api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}
val UpcomingMovieRequestHandler =
object : DiscoverRequestHandler<MovieResult> {
override suspend fun execute(
api: SeerrApi,
pageNumber: Int,
): QueryResult<MovieResult> =
api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let {
QueryResult(it.results.orEmpty(), it.totalResults ?: 0)
}
}

View file

@ -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<T>(
protected val scope: CoroutineScope,
protected val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
) : AbstractList<T?>(),
BlockingList<T?> {
protected var totalCount by mutableIntStateOf(-1)
protected var items by mutableStateOf(ItemList<T>(0, pageSize, mapOf()))
protected val mutex = Mutex()
protected val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, MutableList<T>>()
open suspend fun init(initialPosition: Int = 0): RequestPager<T> {
if (totalCount < 0) {
fetchPageBlocking(initialPosition, true)
}
return this
}
override operator fun get(index: Int): T? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): T? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPageBlocking(index, false)
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<T?>): 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<T>
companion object {
internal const val DEBUG = false
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
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<T>(
val items: List<T>,
val totalCount: Int?,
)

View file

@ -749,5 +749,8 @@
<string name="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wholphin updated to %s</string>
<string name="view_more">View more</string>
<string name="discover_tv">Discover TV Shows</string>
<string name="discover_movies">Discover Movies</string>
</resources>