mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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   ## AI or LLM usage None
This commit is contained in:
parent
1366ee744d
commit
9a3af225a4
16 changed files with 926 additions and 246 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue