mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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"
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.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,6 +170,7 @@ class SeerrDiscoverViewModel
|
||||||
return@launchIO
|
return@launchIO
|
||||||
}
|
}
|
||||||
val result =
|
val result =
|
||||||
|
try {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
SeerrItemType.MOVIE -> {
|
SeerrItemType.MOVIE -> {
|
||||||
DiscoverRating(
|
DiscoverRating(
|
||||||
|
|
@ -166,6 +192,18 @@ class SeerrDiscoverViewModel
|
||||||
DiscoverRating(null, null)
|
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)
|
ratingCache.put(item.id, result)
|
||||||
rating.update {
|
rating.update {
|
||||||
ratingCache.asMap().toMap()
|
ratingCache.asMap().toMap()
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
val newRequest =
|
||||||
requestHandler.prepare(
|
requestHandler.prepare(
|
||||||
request,
|
request,
|
||||||
pageNumber * pageSize,
|
pageNumber * pageSize,
|
||||||
pageSize,
|
pageSize,
|
||||||
setTotalCount,
|
includeTotalCount,
|
||||||
)
|
)
|
||||||
val result = requestHandler.execute(api, newRequest).content
|
val result = requestHandler.execute(api, newRequest).content
|
||||||
if (setTotalCount) {
|
|
||||||
totalCount = result.totalRecordCount.coerceAtLeast(0)
|
|
||||||
}
|
|
||||||
val data = mutableListOf<BaseItem>()
|
val data = mutableListOf<BaseItem>()
|
||||||
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
|
result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) }
|
||||||
cachedPages.put(pageNumber, data)
|
return QueryResult(data, result.totalRecordCount)
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -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="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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue