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" 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 = suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
DiscoverItem( DiscoverItem(
id = movie.id, 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -33,8 +35,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -56,6 +60,7 @@ fun DiscoverItemCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showOverlay: Boolean = true, showOverlay: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -75,16 +80,15 @@ fun DiscoverItemCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val width = Cards.height2x3 * AspectRatios.TALL
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spaceBetween), verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height), modifier = modifier.size(width, Dp.Unspecified),
) { ) {
Card( Card(
modifier = modifier =
Modifier Modifier
.size(Dp.Unspecified, Cards.height2x3) .size(width, Dp.Unspecified)
.aspectRatio(AspectRatios.TALL), .aspectRatio(AspectRatios.TALL),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, 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 @PreviewTvSpec
@Composable @Composable
private fun Preview() { private fun Preview() {
@ -292,6 +378,11 @@ private fun Preview() {
PendingIndicator() PendingIndicator()
AvailableIndicator() AvailableIndicator()
PartiallyAvailableIndicator() 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@ -59,12 +60,8 @@ fun <T> ItemRow(
} }
}, },
) { ) {
Text( ItemRowTitle(title)
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(horizontalPadding), 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.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -882,15 +883,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (showTitle) { if (showTitle) {
Text( GridTitle(title)
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
} }
val endPadding = val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
@ -1146,3 +1139,18 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf() 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState 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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -386,6 +387,7 @@ fun PersonPageContent(
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), stringResource(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
DiscoverRequestType.UNKNOWN,
), ),
onClickItem = { index: Int, item: DiscoverItem -> onClickItem = { index: Int, item: DiscoverItem ->
position = RowColumn(DISCOVER_ROW, index) 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState 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.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -570,6 +571,7 @@ fun MovieDetailsContent(
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), stringResource(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN,
), ),
onClickItem = { index: Int, item: DiscoverItem -> onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -653,6 +654,7 @@ fun SeriesDetailsContent(
DiscoverRowData( DiscoverRowData(
stringResource(R.string.discover), stringResource(R.string.discover),
DataLoadingState.Success(discovered), DataLoadingState.Success(discovered),
type = DiscoverRequestType.UNKNOWN,
), ),
onClickItem = { index: Int, item: DiscoverItem -> onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW 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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrItemType 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.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.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.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader 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.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -68,7 +63,6 @@ class SeerrDiscoverViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val seerrService: SeerrService, private val seerrService: SeerrService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val api: ApiClient,
private val backdropService: BackdropService, private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
val state = MutableStateFlow<DiscoverState>(DiscoverState()) val state = MutableStateFlow<DiscoverState>(DiscoverState())
@ -79,24 +73,53 @@ class SeerrDiscoverViewModel
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
fetchAndUpdateState(seerrService::discoverMovies) { 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) { 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) { 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) { fetchAndUpdateState(seerrService::upcomingMovies) {
this.copy( this.copy(
upcomingMovies = upcomingMovies =
DiscoverRowData(context.getString(R.string.upcoming_movies), it), DiscoverRowData(
context.getString(R.string.upcoming_movies),
it,
DiscoverRequestType.UPCOMING_MOVIES,
),
) )
} }
fetchAndUpdateState(seerrService::upcomingTv) { fetchAndUpdateState(seerrService::upcomingTv) {
this.copy( this.copy(
upcomingTv = 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) { if (item != null) {
backdropService.submit("discover_${item.id}", item.backDropUrl) backdropService.submit("discover_${item.id}", item.backDropUrl)
fetchRating(item) fetchRating(item)
} else {
backdropService.clearBackdrop()
} }
} }
} }
@ -145,26 +170,39 @@ class SeerrDiscoverViewModel
return@launchIO return@launchIO
} }
val result = val result =
when (item.type) { try {
SeerrItemType.MOVIE -> { when (item.type) {
DiscoverRating( SeerrItemType.MOVIE -> {
seerrService.api.moviesApi.movieMovieIdRatingsGet( DiscoverRating(
movieId = item.id, seerrService.api.moviesApi.movieMovieIdRatingsGet(
), movieId = item.id,
) ),
} )
}
SeerrItemType.TV -> { SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
} }
SeerrItemType.PERSON -> { SeerrItemType.PERSON -> {
DiscoverRating(null, null) DiscoverRating(null, null)
} }
SeerrItemType.UNKNOWN -> { SeerrItemType.UNKNOWN -> {
DiscoverRating(null, null)
}
}
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
Timber.w("No rating found for %s", item.id)
DiscoverRating(null, null) 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) ratingCache.put(item.id, result)
rating.update { rating.update {
@ -177,9 +215,10 @@ class SeerrDiscoverViewModel
data class DiscoverRowData( data class DiscoverRowData(
val title: String, val title: String,
val items: DataLoadingState<List<DiscoverItem>>, val items: DataLoadingState<List<DiscoverItem>>,
val type: DiscoverRequestType,
) { ) {
companion object { companion object {
val EMPTY = DiscoverRowData("", DataLoadingState.Pending) val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN)
} }
} }
@ -286,6 +325,15 @@ fun SeerrDiscoverPage(
onLongClickItem = { index, item -> }, onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) }, onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex], 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 =
Modifier Modifier
.fillMaxWidth(), .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.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -129,6 +131,11 @@ sealed class Destination(
val item: DiscoverItem, val item: DiscoverItem,
) : Destination(false) ) : Destination(false)
data class DiscoverMoreResult(
val type: DiscoverRequestType,
val startIndex: Int = SEERR_PAGE_SIZE,
) : Destination(false)
@Serializable @Serializable
data object NowPlaying : Destination(true) 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.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage 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.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage 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 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope 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.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi 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 org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import java.util.function.Predicate
/** /**
* Handles paging for an API request. You must call [init] prior to use. * A [RequestPager] for Jellyfin server queries
*
* 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.
*/ */
class ApiRequestPager<T>( class ApiRequestPager<T>(
val api: ApiClient, val api: ApiClient,
val request: T, val request: T,
val requestHandler: RequestHandler<T>, val requestHandler: RequestHandler<T>,
private val scope: CoroutineScope, scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE, pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8, cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false, private val useSeriesForPrimary: Boolean = false,
) : AbstractList<BaseItem?>(), ) : RequestPager<BaseItem>(scope, pageSize, cacheSize) {
BlockingList<BaseItem?> { override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T>
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>>()
suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> { override suspend fun fetchPage(
if (totalCount < 0) { pageNumber: Int,
fetchPageBlocking(initialPosition, true) includeTotalCount: Boolean,
} ): QueryResult<BaseItem> {
return this val newRequest =
} requestHandler.prepare(
request,
override operator fun get(index: Int): BaseItem? { pageNumber * pageSize,
if (index in 0..<totalCount) { pageSize,
val item = items[index] includeTotalCount,
if (item == null) { )
fetchPage(index) val result = requestHandler.execute(api, newRequest).content
} val data = mutableListOf<BaseItem>()
return item result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
} else { return QueryResult(data, result.totalRecordCount)
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())
}
}
} }
suspend fun refreshItem( suspend fun refreshItem(
@ -161,7 +78,7 @@ class ApiRequestPager<T>(
if (page != null && index in page.indices) { if (page != null && index in page.indices) {
page[index] = item page[index] = item
cachedPages.put(pageNumber, page) 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) 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> { interface RequestHandler<T> {
/** /**
@ -235,6 +123,7 @@ interface RequestHandler<T> {
): Response<BaseItemDtoQueryResult> ): Response<BaseItemDtoQueryResult>
} }
@Serializable
val GetItemsRequestHandler = val GetItemsRequestHandler =
object : RequestHandler<GetItemsRequest> { object : RequestHandler<GetItemsRequest> {
override fun prepare( override fun prepare(
@ -255,6 +144,7 @@ val GetItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
} }
@Serializable
val GetEpisodesRequestHandler = val GetEpisodesRequestHandler =
object : RequestHandler<GetEpisodesRequest> { object : RequestHandler<GetEpisodesRequest> {
override fun prepare( override fun prepare(
@ -274,6 +164,7 @@ val GetEpisodesRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
} }
@Serializable
val GetResumeItemsRequestHandler = val GetResumeItemsRequestHandler =
object : RequestHandler<GetResumeItemsRequest> { object : RequestHandler<GetResumeItemsRequest> {
override fun prepare( override fun prepare(
@ -294,6 +185,7 @@ val GetResumeItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
} }
@Serializable
val GetNextUpRequestHandler = val GetNextUpRequestHandler =
object : RequestHandler<GetNextUpRequest> { object : RequestHandler<GetNextUpRequest> {
override fun prepare( override fun prepare(
@ -314,6 +206,7 @@ val GetNextUpRequestHandler =
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
} }
@Serializable
val GetSuggestionsRequestHandler = val GetSuggestionsRequestHandler =
object : RequestHandler<GetSuggestionsRequest> { object : RequestHandler<GetSuggestionsRequest> {
override fun prepare( override fun prepare(
@ -334,6 +227,7 @@ val GetSuggestionsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
} }
@Serializable
val GetPlaylistItemsRequestHandler = val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> { object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare( override fun prepare(
@ -353,6 +247,7 @@ val GetPlaylistItemsRequestHandler =
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
} }
@Serializable
val GetGenresRequestHandler = val GetGenresRequestHandler =
object : RequestHandler<GetGenresRequest> { object : RequestHandler<GetGenresRequest> {
override fun prepare( override fun prepare(
@ -392,6 +287,7 @@ val GetProgramsDtoHandler =
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
} }
@Serializable
val GetPersonsHandler = val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> { object : RequestHandler<GetPersonsRequest> {
override fun prepare( 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="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string> <string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wholphin updated to %s</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> </resources>